Initial release of the oVirt/RHV Engine API simulator.
Stateful FastAPI lab with contract packs, Compose/Helm, Docker Hub release targets, and Pulumi coverage across all Engine series (GET/POST/PUT/DELETE/HEAD).
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.venv
|
||||
__pycache__
|
||||
.mypy_cache
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
.env
|
||||
htmlcov
|
||||
docs
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
DATABASE_URL=postgresql://ovirt:ovirt@postgres:5432/ovirt_simulator
|
||||
TEST_DATABASE_URL=postgresql://ovirt:ovirt@postgres:5432/ovirt_simulator
|
||||
LOG_LEVEL=INFO
|
||||
TICKET_SIGNING_KEY=development-only-signing-key-change-me
|
||||
APP_PORT=8080
|
||||
OVIRT_SERIES=4.5
|
||||
SEED_PROFILE=minimal
|
||||
# Host publish (only two ports): Engine HTTPS + Web UI.
|
||||
OVIRT_ENGINE_PORT=443
|
||||
OVIRT_UI_PORT=5000
|
||||
@@ -0,0 +1,61 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
offline:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build images
|
||||
run: |
|
||||
cp -n .env.example .env || true
|
||||
docker compose build simulator dev
|
||||
|
||||
- name: Critical lint (syntax / undefined names)
|
||||
run: docker compose run --rm --no-deps dev ruff check app tests --select E9,F63,F7,F82
|
||||
|
||||
- name: Offline unit + contract tests
|
||||
run: make test
|
||||
|
||||
- name: Helm template
|
||||
run: |
|
||||
curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
|
||||
make helm-template
|
||||
|
||||
integration:
|
||||
runs-on: ubuntu-latest
|
||||
needs: offline
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Start stack
|
||||
run: |
|
||||
cp -n .env.example .env || true
|
||||
make up
|
||||
make seed
|
||||
|
||||
- name: Smoke
|
||||
run: make smoke
|
||||
|
||||
- name: Contract pack activate
|
||||
run: |
|
||||
curl -sf -X POST http://127.0.0.1:5000/ui/api/ovirt/contracts/activate \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"series":"3.6"}'
|
||||
curl -sf -X POST http://127.0.0.1:5000/ui/api/ovirt/contracts/activate \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"series":"4.5"}'
|
||||
|
||||
- name: Integration tests
|
||||
run: make test-integration
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
# Hidden directories (.cursor/, .pytest_cache/, .mypy_cache/, .ruff_cache/, …)
|
||||
.*/
|
||||
# Keep GitHub Actions / repo metadata trackable despite the rule above.
|
||||
!.github/
|
||||
!.github/**
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
._*
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
.apdisk
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
|
||||
# Environment / secrets (keep .env.example tracked)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
*.egg
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
dist/
|
||||
build/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
share/python-wheels/
|
||||
*.manifest
|
||||
*.spec
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
|
||||
# Test / coverage leftovers (directories also covered by .*/)
|
||||
.coverage
|
||||
.coverage.*
|
||||
coverage.xml
|
||||
htmlcov/
|
||||
.cache
|
||||
nosetests.xml
|
||||
pytestdebug.log
|
||||
hypothesis/
|
||||
|
||||
# Editors / IDE leftovers outside .*
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
|
||||
# Docker / local runtime
|
||||
*.log
|
||||
docker-compose.override.yml
|
||||
|
||||
# Regenerated probe artifacts
|
||||
evidence/_api_surface_probe.json
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
FROM python:3.13-slim AS builder
|
||||
|
||||
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
VIRTUAL_ENV=/opt/venv
|
||||
RUN python -m venv "$VIRTUAL_ENV"
|
||||
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
WORKDIR /build
|
||||
COPY pyproject.toml README.md ./
|
||||
COPY app ./app
|
||||
COPY contracts ./contracts
|
||||
COPY evidence ./evidence
|
||||
RUN pip install --upgrade "pip>=25.1,<26" && pip install .
|
||||
|
||||
FROM python:3.13-slim AS runtime
|
||||
|
||||
ARG APP_VERSION=0.1.0
|
||||
LABEL org.opencontainers.image.title="ovirt-api-simulator" \
|
||||
org.opencontainers.image.version="$APP_VERSION" \
|
||||
org.opencontainers.image.source="https://github.com/inecs/ovirt-api-simulator"
|
||||
ENV PATH="/opt/venv/bin:$PATH" \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
APP_HOST=0.0.0.0 \
|
||||
APP_PORT=8080 \
|
||||
OVIRT_SERIES=4.5
|
||||
RUN groupadd --system --gid 10001 simulator \
|
||||
&& useradd --system --uid 10001 --gid simulator --home-dir /app --no-create-home simulator
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
COPY contracts/ovirt/ /app/contracts/ovirt/
|
||||
COPY evidence/ /app/evidence/
|
||||
WORKDIR /app
|
||||
USER 10001:10001
|
||||
# Internal listen only — Engine ports are on api-gateway.
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 \
|
||||
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/live', timeout=2)"]
|
||||
ENTRYPOINT ["uvicorn", "app.main:app"]
|
||||
CMD ["--host", "0.0.0.0", "--port", "8080"]
|
||||
|
||||
FROM python:3.13-slim AS dev
|
||||
|
||||
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
VIRTUAL_ENV=/opt/venv \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
RUN python -m venv "$VIRTUAL_ENV"
|
||||
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
WORKDIR /workspace
|
||||
COPY pyproject.toml README.md ./
|
||||
COPY app ./app
|
||||
COPY tests ./tests
|
||||
COPY contracts ./contracts
|
||||
COPY evidence ./evidence
|
||||
COPY tools ./tools
|
||||
RUN pip install --upgrade "pip>=25.1,<26" && pip install -e '.[dev]'
|
||||
ENTRYPOINT []
|
||||
CMD ["bash"]
|
||||
@@ -0,0 +1,13 @@
|
||||
Copyright 2026 ovirt-api-simulator contributors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,154 @@
|
||||
COMPOSE ?= docker compose
|
||||
SERVICE_DEV := dev
|
||||
SERVICE_SIM := simulator
|
||||
PYTEST_OFFLINE := -m "not integration and not compatibility"
|
||||
|
||||
# Docker Hub release image (runtime target only — not the local bind-mount "dev" image).
|
||||
DOCKERHUB_USER ?= inecs
|
||||
IMAGE_NAME ?= ovirt-api-simulator
|
||||
VERSION ?= $(shell sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml)
|
||||
DOCKER_IMAGE ?= $(DOCKERHUB_USER)/$(IMAGE_NAME)
|
||||
PUSH_LATEST ?= 1
|
||||
|
||||
COMPOSE_RELEASE ?= $(COMPOSE) -f docker-compose.release.yml
|
||||
HELM_CHART ?= ./helm/ovirt-api-simulator
|
||||
LAB ?= ./pulumi-tests
|
||||
|
||||
.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 \
|
||||
helm-template generate-packs \
|
||||
release release-build release-up release-down release-seed \
|
||||
test-pulumi-smoke test-pulumi pulumi-tests \
|
||||
test-smoke-all test-all clean-test-resources
|
||||
|
||||
help: ## Show available commands
|
||||
@awk 'BEGIN {FS = ":.*## "}; /^[a-zA-Z0-9_-]+:.*## / {printf "%-22s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||
|
||||
install: ## Build runtime and development images
|
||||
@test -f .env || cp .env.example .env
|
||||
$(COMPOSE) build simulator $(SERVICE_DEV)
|
||||
|
||||
generate-packs: ## Regenerate contracts/ovirt series packs
|
||||
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) python tools/ovirt_api_inventory/generate_packs.py
|
||||
|
||||
format: ## Format Python sources
|
||||
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) ruff format .
|
||||
|
||||
lint: ## Run Ruff lint checks
|
||||
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) ruff check app tests
|
||||
|
||||
typecheck: ## Run strict mypy checks
|
||||
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) mypy app/ovirt app/web/routes.py app/main.py app/config.py app/lifespan.py
|
||||
|
||||
test: ## Run offline unit and contract tests
|
||||
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) pytest $(PYTEST_OFFLINE)
|
||||
|
||||
test-unit: ## Run unit tests
|
||||
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) pytest tests/unit tests/ovirt -q
|
||||
|
||||
test-integration: ## Run tests that require PostgreSQL
|
||||
@test -f .env || cp .env.example .env
|
||||
$(COMPOSE) up -d postgres migrate
|
||||
$(COMPOSE) run --rm $(SERVICE_DEV) pytest -m integration tests/ovirt -q
|
||||
|
||||
test-contract: ## Validate oVirt contract packs
|
||||
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) pytest -m contract tests/ovirt -q
|
||||
|
||||
test-surface: ## Probe Engine API surface across series
|
||||
@test -f .env || cp .env.example .env
|
||||
$(COMPOSE) up -d --wait
|
||||
$(COMPOSE) run --rm --no-deps --entrypoint python $(SERVICE_SIM) -m pytest tests/ovirt -q --tb=line
|
||||
|
||||
test-versions: ## All Engine series packs with seeded inventory assertions
|
||||
@test -f .env || cp .env.example .env
|
||||
$(COMPOSE) up -d --wait
|
||||
$(COMPOSE) run --rm --entrypoint python $(SERVICE_SIM) -m app.ovirt.seed_cli --profile minimal
|
||||
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) pytest -m integration tests/ovirt/test_api_versions.py -q --tb=short
|
||||
|
||||
coverage: ## Offline coverage
|
||||
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) pytest $(PYTEST_OFFLINE) --cov=app/ovirt --cov-report=term-missing
|
||||
|
||||
up: ## Start PostgreSQL, simulator, and Engine gateway
|
||||
@test -f .env || cp .env.example .env
|
||||
$(COMPOSE) up -d --build --wait
|
||||
|
||||
down: ## Stop local services
|
||||
$(COMPOSE) down
|
||||
|
||||
restart: ## Rebuild and restart the stack
|
||||
@test -f .env || cp .env.example .env
|
||||
$(COMPOSE) up -d --build --force-recreate --wait
|
||||
|
||||
logs: ## Follow logs
|
||||
$(COMPOSE) logs -f
|
||||
|
||||
seed: ## Seed minimal lab
|
||||
$(COMPOSE) run --rm --entrypoint python $(SERVICE_SIM) -m app.ovirt.seed_cli --profile minimal
|
||||
|
||||
seed-demo: ## Seed demo datacenter (~1000 VMs)
|
||||
$(COMPOSE) run --rm --entrypoint python $(SERVICE_SIM) -m app.ovirt.seed_cli --profile demo
|
||||
|
||||
smoke: ## Quick Engine auth + list VMs smoke
|
||||
@curl -skf -u 'admin@internal:secret' -H 'Accept: application/json' -H 'Version: 4' \
|
||||
https://127.0.0.1:$${OVIRT_ENGINE_PORT:-443}/ovirt-engine/api/vms >/dev/null
|
||||
@echo "smoke ok"
|
||||
|
||||
helm-template: ## Render Helm chart
|
||||
helm template ovirt-sim $(HELM_CHART)
|
||||
|
||||
clean: ## Remove caches
|
||||
rm -rf .pytest_cache .mypy_cache .ruff_cache .coverage coverage.xml
|
||||
find . -type d -name __pycache__ -prune -exec rm -rf {} +
|
||||
|
||||
ci: format lint test-unit ## Local CI gate
|
||||
|
||||
shell: ## Dev shell in container
|
||||
$(COMPOSE) run --rm $(SERVICE_DEV) bash
|
||||
|
||||
release-build: ## Build the runtime image tagged for Docker Hub (no push)
|
||||
@test -n "$(VERSION)" || (echo "VERSION is empty; set VERSION=... or version in pyproject.toml" >&2; exit 1)
|
||||
@echo "Building $(DOCKER_IMAGE):$(VERSION) (target=runtime)"
|
||||
docker build \
|
||||
--target runtime \
|
||||
--build-arg APP_VERSION=$(VERSION) \
|
||||
-t $(DOCKER_IMAGE):$(VERSION) \
|
||||
$(if $(filter 1 true yes,$(PUSH_LATEST)),-t $(DOCKER_IMAGE):latest,) \
|
||||
.
|
||||
|
||||
release: release-build ## Build and push the runtime image to Docker Hub
|
||||
@echo "Pushing $(DOCKER_IMAGE):$(VERSION)"
|
||||
@docker push $(DOCKER_IMAGE):$(VERSION)
|
||||
@if [ "$(PUSH_LATEST)" = "1" ] || [ "$(PUSH_LATEST)" = "true" ] || [ "$(PUSH_LATEST)" = "yes" ]; then \
|
||||
echo "Pushing $(DOCKER_IMAGE):latest"; \
|
||||
docker push $(DOCKER_IMAGE):latest; \
|
||||
fi
|
||||
@echo "Released $(DOCKER_IMAGE):$(VERSION)$(if $(filter 1 true yes,$(PUSH_LATEST)), and $(DOCKER_IMAGE):latest,)"
|
||||
|
||||
release-up: ## Pull and start the published Hub stack (docker-compose.release.yml)
|
||||
IMAGE_TAG="$${IMAGE_TAG:-$(VERSION)}" DOCKER_IMAGE="$(DOCKER_IMAGE)" $(COMPOSE_RELEASE) pull
|
||||
IMAGE_TAG="$${IMAGE_TAG:-$(VERSION)}" DOCKER_IMAGE="$(DOCKER_IMAGE)" $(COMPOSE_RELEASE) up -d --wait
|
||||
|
||||
release-down: ## Stop the published Hub stack
|
||||
$(COMPOSE_RELEASE) down
|
||||
|
||||
release-seed: ## Seed the published Hub stack (PROFILE=minimal by default)
|
||||
SEED_PROFILE="$${PROFILE:-minimal}" IMAGE_TAG="$${IMAGE_TAG:-$(VERSION)}" DOCKER_IMAGE="$(DOCKER_IMAGE)" \
|
||||
$(COMPOSE_RELEASE) run --rm --entrypoint python simulator -m app.ovirt.seed_cli --profile "$${PROFILE:-minimal}"
|
||||
|
||||
# --- Pulumi contract-coverage lab (pulumi-tests) ---
|
||||
test-pulumi-smoke: ## Pulumi contract-coverage smoke
|
||||
$(MAKE) -C $(LAB) test-pulumi-smoke
|
||||
|
||||
test-pulumi: ## Pulumi full contract coverage (all series × all ops)
|
||||
$(MAKE) -C $(LAB) test-pulumi
|
||||
|
||||
pulumi-tests: test-pulumi ## Alias: full Pulumi contract coverage suite
|
||||
|
||||
test-smoke-all: ## Client lab smoke
|
||||
$(MAKE) -C $(LAB) test-smoke-all
|
||||
|
||||
test-all: ## Client lab full suite (Pulumi contract coverage)
|
||||
$(MAKE) -C $(LAB) test-all
|
||||
|
||||
clean-test-resources: ## Cleanup lab-created resources
|
||||
$(MAKE) -C $(LAB) clean-test-resources
|
||||
@@ -0,0 +1,119 @@
|
||||
**Language / Язык:** [English](README.md) | [Русский](README.ru.md)
|
||||
|
||||
# ovirt-api-simulator
|
||||
|
||||
Stateful laboratory simulator for the **oVirt / RHV Engine REST API**. Use it to
|
||||
test API clients and infrastructure tooling without a real Engine or hypervisors.
|
||||
|
||||
> **Lab only.** This is not a production Engine, does not run hypervisors, and
|
||||
> ships with default credentials plus a self-signed TLS certificate. Do not
|
||||
> expose it to untrusted networks.
|
||||
|
||||
The simulator is backed by PostgreSQL, driven by generated Engine contract packs,
|
||||
and exposes the same `/ovirt-engine/api` and SSO OAuth2 surfaces as oVirt Engine.
|
||||
Semantic handlers persist mutations; long-running work is tracked as Engine jobs.
|
||||
|
||||
## Quick start (development checkout)
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
make up
|
||||
make seed # or: make seed-demo (~1000 VMs)
|
||||
```
|
||||
|
||||
| Surface | URL |
|
||||
|---------|-----|
|
||||
| Web UI console | http://127.0.0.1:5000 |
|
||||
| Engine API | https://127.0.0.1/ovirt-engine/api |
|
||||
|
||||
Only **two** host ports are published by default: Engine `443` and UI `5000`.
|
||||
Override with `OVIRT_ENGINE_PORT` / `OVIRT_UI_PORT` if those are taken.
|
||||
|
||||
**Credentials:** `admin@internal` / `secret`
|
||||
Also: `ops@internal`, `developer@internal`, `demo@internal` / `secret`
|
||||
|
||||
```bash
|
||||
# OAuth password grant
|
||||
curl -k -X POST https://127.0.0.1/ovirt-engine/sso/oauth/token \
|
||||
-d 'grant_type=password&username=admin@internal&password=secret&scope=ovirt-app-api'
|
||||
|
||||
# List VMs (HTTP Basic)
|
||||
curl -k -u 'admin@internal:secret' -H 'Accept: application/json' -H 'Version: 4' \
|
||||
https://127.0.0.1/ovirt-engine/api/vms
|
||||
```
|
||||
|
||||
## Quick start (published image)
|
||||
|
||||
Image: [`inecs/ovirt-api-simulator`](https://hub.docker.com/r/inecs/ovirt-api-simulator)
|
||||
|
||||
Requires this repository checkout (gateway TLS certs and nginx config are bind-mounted):
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.release.yml up -d
|
||||
docker compose -f docker-compose.release.yml run --rm --entrypoint python \
|
||||
simulator -m app.ovirt.seed_cli --profile minimal
|
||||
|
||||
curl -skf https://127.0.0.1/health/ready
|
||||
curl -k -u 'admin@internal:secret' -H 'Accept: application/json' -H 'Version: 4' \
|
||||
https://127.0.0.1/ovirt-engine/api/vms
|
||||
```
|
||||
|
||||
Or: `make release-up && make release-seed PROFILE=minimal`
|
||||
|
||||
| Surface | URL |
|
||||
|---------|-----|
|
||||
| Web UI console | http://127.0.0.1:5000 |
|
||||
| Engine API | https://127.0.0.1/ovirt-engine/api |
|
||||
|
||||
**Credentials:** `admin@internal` / `secret`
|
||||
|
||||
## Features
|
||||
|
||||
- Real Engine paths: `/ovirt-engine/api`, `/ovirt-engine/api/v3|v4`, SSO OAuth2
|
||||
- XML + JSON representations (`Accept` / `Content-Type`)
|
||||
- Series packs **3.0–3.6, 4.3–4.5, master** with operation deltas
|
||||
- Stateful PostgreSQL inventory + async jobs
|
||||
- Seed profiles: `minimal` and `demo` (~1000 VMs)
|
||||
- Web UI with oVirt branding (`#0076B6` / charcoal `#1D2226`)
|
||||
- Docker Compose + Helm
|
||||
- API tests + [`pulumi-tests/`](pulumi-tests/README.md) (Pulumi contract coverage)
|
||||
|
||||
## Make targets
|
||||
|
||||
```bash
|
||||
make up
|
||||
make seed
|
||||
make seed-demo
|
||||
make test-unit
|
||||
make test-integration
|
||||
make test-pulumi-smoke # Pulumi smoke
|
||||
make test-pulumi # all series × all contract ops + HTML report
|
||||
make pulumi-tests # alias for test-pulumi
|
||||
make test-all # alias for test-pulumi
|
||||
```
|
||||
|
||||
Docker Hub release (requires `docker login` as the Hub owner; see
|
||||
[Operations](docs/operations.md)):
|
||||
|
||||
```bash
|
||||
make release # inecs/ovirt-api-simulator:<pyproject version> + :latest
|
||||
make release VERSION=0.2.0 # override tag
|
||||
make release-build # build/tag only, no push
|
||||
make release-up && make release-seed # run the published stack locally
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
| Guide | Description |
|
||||
|---|---|
|
||||
| [Getting started](docs/getting-started.md) | First lab session |
|
||||
| [Ports](docs/ports.md) | Published Engine + UI ports |
|
||||
| [Authentication](docs/authentication.md) | Basic, OAuth2, sessions |
|
||||
| [API versions](docs/api-versions.md) | Series packs and Version header |
|
||||
| [Configuration](docs/configuration.md) | Environment and Compose |
|
||||
| [Seed profiles](docs/seed-profiles.md) | `minimal` / `demo` |
|
||||
| [Kubernetes / Helm](docs/kubernetes.md) | Cluster install |
|
||||
| [Full index](docs/README.md) | All guides |
|
||||
|
||||
Russian mirrors: [`README.ru.md`](README.ru.md) and [`docs/ru/`](docs/ru/README.md).
|
||||
Switch language with the header on each page.
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
**Language / Язык:** [English](README.md) | [Русский](README.ru.md)
|
||||
|
||||
# ovirt-api-simulator
|
||||
|
||||
Stateful-лабораторный симулятор **oVirt / RHV Engine REST API**. Нужен для
|
||||
тестирования API-клиентов и инфраструктурных инструментов без реального Engine
|
||||
и гипервизоров.
|
||||
|
||||
> **Только лаборатория.** Это не production Engine, гипервизоры не запускаются,
|
||||
> в комплекте дефолтные учётные данные и self-signed TLS. Не выставляйте в
|
||||
> недоверенные сети.
|
||||
|
||||
Симулятор работает на PostgreSQL, опирается на сгенерированные контрактные packs
|
||||
Engine и предоставляет те же поверхности `/ovirt-engine/api` и SSO OAuth2, что и
|
||||
oVirt Engine. Семантические обработчики сохраняют мутации; длительные операции
|
||||
отслеживаются как Engine jobs.
|
||||
|
||||
## Быстрый старт (разработка из репозитория)
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
make up
|
||||
make seed # или: make seed-demo (~1000 ВМ)
|
||||
```
|
||||
|
||||
| Поверхность | URL |
|
||||
|---------|-----|
|
||||
| Web UI консоль | http://127.0.0.1:5000 |
|
||||
| Engine API | https://127.0.0.1/ovirt-engine/api |
|
||||
|
||||
По умолчанию публикуются ровно **два** host-порта: Engine `443` и UI `5000`.
|
||||
Переопределите через `OVIRT_ENGINE_PORT` / `OVIRT_UI_PORT`, если они заняты.
|
||||
|
||||
**Учётные данные:** `admin@internal` / `secret`
|
||||
Также: `ops@internal`, `developer@internal`, `demo@internal` / `secret`
|
||||
|
||||
```bash
|
||||
# OAuth password grant
|
||||
curl -k -X POST https://127.0.0.1/ovirt-engine/sso/oauth/token \
|
||||
-d 'grant_type=password&username=admin@internal&password=secret&scope=ovirt-app-api'
|
||||
|
||||
# Список ВМ (HTTP Basic)
|
||||
curl -k -u 'admin@internal:secret' -H 'Accept: application/json' -H 'Version: 4' \
|
||||
https://127.0.0.1/ovirt-engine/api/vms
|
||||
```
|
||||
|
||||
## Быстрый старт (опубликованный образ)
|
||||
|
||||
Образ: [`inecs/ovirt-api-simulator`](https://hub.docker.com/r/inecs/ovirt-api-simulator)
|
||||
|
||||
Нужен checkout этого репозитория (TLS-сертификаты gateway и nginx-конфиг
|
||||
монтируются с диска):
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.release.yml up -d
|
||||
docker compose -f docker-compose.release.yml run --rm --entrypoint python \
|
||||
simulator -m app.ovirt.seed_cli --profile minimal
|
||||
|
||||
curl -skf https://127.0.0.1/health/ready
|
||||
curl -k -u 'admin@internal:secret' -H 'Accept: application/json' -H 'Version: 4' \
|
||||
https://127.0.0.1/ovirt-engine/api/vms
|
||||
```
|
||||
|
||||
Или: `make release-up && make release-seed PROFILE=minimal`
|
||||
|
||||
| Поверхность | URL |
|
||||
|---------|-----|
|
||||
| Web UI консоль | http://127.0.0.1:5000 |
|
||||
| Engine API | https://127.0.0.1/ovirt-engine/api |
|
||||
|
||||
**Учётные данные:** `admin@internal` / `secret`
|
||||
|
||||
## Возможности
|
||||
|
||||
- Реальные пути Engine: `/ovirt-engine/api`, `/ovirt-engine/api/v3|v4`, SSO OAuth2
|
||||
- Представления XML + JSON (`Accept` / `Content-Type`)
|
||||
- Series packs **3.0–3.6, 4.3–4.5, master** с дельтами операций
|
||||
- Stateful-инвентарь в PostgreSQL + асинхронные jobs
|
||||
- Профили seed: `minimal` и `demo` (~1000 ВМ)
|
||||
- Web UI с брендингом oVirt (`#0076B6` / charcoal `#1D2226`)
|
||||
- Docker Compose + Helm
|
||||
- API-тесты + [`pulumi-tests/`](pulumi-tests/README.ru.md) (покрытие контрактов Pulumi)
|
||||
|
||||
## Цели Make
|
||||
|
||||
```bash
|
||||
make up
|
||||
make seed
|
||||
make seed-demo
|
||||
make test-unit
|
||||
make test-integration
|
||||
make test-pulumi-smoke # Pulumi smoke
|
||||
make test-pulumi # все series × все contract ops + HTML-отчёт
|
||||
make pulumi-tests # alias для test-pulumi
|
||||
make test-all # alias для test-pulumi
|
||||
```
|
||||
|
||||
Публикация в Docker Hub (нужен `docker login` владельца Hub; см.
|
||||
[Эксплуатация](docs/ru/operations.md)):
|
||||
|
||||
```bash
|
||||
make release # inecs/ovirt-api-simulator:<версия pyproject> + :latest
|
||||
make release VERSION=0.2.0 # переопределить тег
|
||||
make release-build # только build/tag, без push
|
||||
make release-up && make release-seed # запустить опубликованный стек локально
|
||||
```
|
||||
|
||||
## Документация
|
||||
|
||||
| Руководство | Описание |
|
||||
|---|---|
|
||||
| [Быстрый старт](docs/ru/getting-started.md) | Первая лабораторная сессия |
|
||||
| [Порты](docs/ru/ports.md) | Опубликованные порты Engine + UI |
|
||||
| [Аутентификация](docs/ru/authentication.md) | Basic, OAuth2, сессии |
|
||||
| [Версии API](docs/ru/api-versions.md) | Series packs и заголовок Version |
|
||||
| [Конфигурация](docs/ru/configuration.md) | Окружение и Compose |
|
||||
| [Профили seed](docs/ru/seed-profiles.md) | `minimal` / `demo` |
|
||||
| [Kubernetes / Helm](docs/ru/kubernetes.md) | Установка в кластер |
|
||||
| [Полный индекс](docs/ru/README.md) | Все руководства |
|
||||
|
||||
Английские версии: [`README.md`](README.md) и [`docs/`](docs/README.md).
|
||||
Переключайте язык с помощью заголовка на каждой странице.
|
||||
@@ -0,0 +1 @@
|
||||
"""oVirt / RHV Engine API simulator application package."""
|
||||
@@ -0,0 +1 @@
|
||||
"""HTTP adapters."""
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Base external error representation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
"""A safe error intended for the Engine API boundary."""
|
||||
|
||||
def __init__(
|
||||
self, status_code: int, message: str, errors: dict[str, str] | None = None
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.message = message
|
||||
self.errors = errors
|
||||
|
||||
|
||||
class ContractValidationError(ApiError):
|
||||
def __init__(self, errors: dict[str, str]) -> None:
|
||||
super().__init__(400, "parameter verification failed", errors)
|
||||
|
||||
|
||||
async def api_error_handler(_request: Request, exc: Exception) -> JSONResponse:
|
||||
if not isinstance(exc, ApiError):
|
||||
raise TypeError("api_error_handler received an incompatible exception")
|
||||
body: dict[str, Any] = {"data": None, "message": exc.message}
|
||||
if exc.errors is not None:
|
||||
body["errors"] = exc.errors
|
||||
return JSONResponse(status_code=exc.status_code, content=body)
|
||||
|
||||
|
||||
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""Log internal failures and return a stable non-FastAPI error envelope."""
|
||||
|
||||
logger.exception(
|
||||
"unhandled request error",
|
||||
extra={"request_id": getattr(request.state, "request_id", None), "path": request.url.path},
|
||||
)
|
||||
body: dict[str, Any] = {
|
||||
"data": None,
|
||||
"errors": {"internal": "internal server error"},
|
||||
}
|
||||
return JSONResponse(status_code=500, content=body)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Request correlation and access logging middleware."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RequestHandler = Callable[[Request], Awaitable[Response]]
|
||||
|
||||
|
||||
class RequestContextMiddleware(BaseHTTPMiddleware):
|
||||
"""Attach a bounded request ID and log one structured completion event."""
|
||||
|
||||
def __init__(self, app: object, header_name: str) -> None:
|
||||
super().__init__(app) # type: ignore[arg-type]
|
||||
self._header_name = header_name
|
||||
|
||||
async def dispatch(self, request: Request, call_next: RequestHandler) -> Response:
|
||||
supplied = request.headers.get(self._header_name, "")
|
||||
request_id = supplied if 0 < len(supplied) <= 128 else str(uuid.uuid4())
|
||||
request.state.request_id = request_id
|
||||
started = time.monotonic()
|
||||
response = await call_next(request)
|
||||
response.headers[self._header_name] = request_id
|
||||
logger.info(
|
||||
"request completed",
|
||||
extra={
|
||||
"request_id": request_id,
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"status": response.status_code,
|
||||
"duration_ms": round((time.monotonic() - started) * 1000, 3),
|
||||
},
|
||||
)
|
||||
return response
|
||||
@@ -0,0 +1,94 @@
|
||||
"""OpenAPI tag resolution for Engine API and simulator routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
_COLLECTION_LABELS: dict[str, str] = {
|
||||
"vms": "VMs",
|
||||
"disks": "Disks",
|
||||
"hosts": "Hosts",
|
||||
"clusters": "Clusters",
|
||||
"datacenters": "Data Centers",
|
||||
"networks": "Networks",
|
||||
"vnicprofiles": "vNIC Profiles",
|
||||
"storagedomains": "Storage Domains",
|
||||
"storageconnections": "Storage Connections",
|
||||
"templates": "Templates",
|
||||
"users": "Users",
|
||||
"groups": "Groups",
|
||||
"roles": "Roles",
|
||||
"permissions": "Permissions",
|
||||
"domains": "Domains",
|
||||
"events": "Events",
|
||||
"jobs": "Jobs",
|
||||
"tags": "Tags",
|
||||
"bookmarks": "Bookmarks",
|
||||
"affinitylabels": "Affinity Labels",
|
||||
"instancetypes": "Instance Types",
|
||||
"macpools": "MAC Pools",
|
||||
"schedulingpolicies": "Scheduling Policies",
|
||||
"schedulingpolicyunits": "Scheduling Policy Units",
|
||||
"clusterlevels": "Cluster Levels",
|
||||
"icons": "Icons",
|
||||
"operatingsystems": "Operating Systems",
|
||||
"networkfilters": "Network Filters",
|
||||
"vmpools": "VM Pools",
|
||||
"katelloerrata": "Katello Errata",
|
||||
"externalhostproviders": "External Host Providers",
|
||||
"openstacknetworkproviders": "OpenStack Network Providers",
|
||||
"openstackimageproviders": "OpenStack Image Providers",
|
||||
"openstackvolumeproviders": "OpenStack Volume Providers",
|
||||
"imagetransfers": "Image Transfers",
|
||||
"options": "Options",
|
||||
}
|
||||
|
||||
|
||||
def contract_openapi_tag(path: str) -> str:
|
||||
"""Map an Engine API path to a Swagger UI category."""
|
||||
|
||||
parts = [part for part in path.strip("/").split("/") if part]
|
||||
if parts[:2] == ["ovirt-engine", "api"]:
|
||||
parts = parts[2:]
|
||||
if parts and parts[0] in {"v3", "v4"}:
|
||||
parts = parts[1:]
|
||||
if not parts:
|
||||
return "engine"
|
||||
root = parts[0]
|
||||
return _COLLECTION_LABELS.get(root, root.replace("-", " ").title())
|
||||
|
||||
|
||||
def contract_openapi_tags(path: str, renderer: str | None = None) -> list[str]:
|
||||
"""Return OpenAPI tags for a contract route.
|
||||
|
||||
``renderer`` is accepted for call-site compatibility; Engine API has a
|
||||
single representation surface.
|
||||
"""
|
||||
|
||||
del renderer
|
||||
return [contract_openapi_tag(path)]
|
||||
|
||||
|
||||
def openapi_tag_metadata() -> list[dict[str, str]]:
|
||||
"""Descriptions shown in Swagger UI for each tag group."""
|
||||
|
||||
tags: list[dict[str, str]] = [
|
||||
{
|
||||
"name": "engine",
|
||||
"description": "oVirt Engine REST API root under `/ovirt-engine/api`.",
|
||||
},
|
||||
{
|
||||
"name": "sso",
|
||||
"description": "Engine SSO OAuth2 token endpoints.",
|
||||
},
|
||||
{
|
||||
"name": "Simulator",
|
||||
"description": "Health checks, compatibility reports, and the web console.",
|
||||
},
|
||||
]
|
||||
for label in sorted(set(_COLLECTION_LABELS.values())):
|
||||
tags.append(
|
||||
{
|
||||
"name": label,
|
||||
"description": f"Engine {label} collection under `/ovirt-engine/api`.",
|
||||
}
|
||||
)
|
||||
return tags
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Typed application configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field, SecretStr
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Runtime settings loaded from environment variables and an optional `.env`."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
frozen=True,
|
||||
)
|
||||
|
||||
app_name: str = "ovirt-api-simulator"
|
||||
app_host: str = "0.0.0.0" # noqa: S104
|
||||
# Internal listen port. Public Engine ports are published by api-gateway.
|
||||
app_port: int = Field(default=8080, ge=1, le=65535)
|
||||
database_url: SecretStr = SecretStr("postgresql://ovirt:ovirt@localhost:5432/ovirt_simulator")
|
||||
db_pool_min_size: int = Field(default=1, ge=1, le=100)
|
||||
db_pool_max_size: int = Field(default=10, ge=1, le=100)
|
||||
db_connect_timeout_seconds: float = Field(default=10.0, gt=0, le=60)
|
||||
db_command_timeout_seconds: float = Field(default=30.0, gt=0, le=300)
|
||||
log_level: str = "INFO"
|
||||
request_id_header: str = "X-Request-ID"
|
||||
contract_snapshot: Path | None = None
|
||||
compatibility_evidence: Path | None = None
|
||||
contract_fallback: Literal["error", "schema-default", "fixture"] = "error"
|
||||
catalog_artifact_url_6: str = "stub://ovirt/4.3/api-contract"
|
||||
catalog_artifact_url_7: str = "stub://ovirt/4.4/api-contract"
|
||||
catalog_artifact_url_8: str = "stub://ovirt/4.5/api-contract"
|
||||
catalog_artifact_url_9: str = "stub://ovirt/master/api-contract"
|
||||
ticket_signing_key: SecretStr = SecretStr("development-only-signing-key-change-me")
|
||||
task_worker_concurrency: int = Field(default=2, ge=1, le=32)
|
||||
task_lease_seconds: float = Field(default=30.0, gt=1, le=300)
|
||||
simulation_time_scale: float = Field(default=10.0, gt=0, le=10000)
|
||||
ovirt_series: str = "4.5"
|
||||
|
||||
def catalog_artifact_urls(self) -> dict[int, str]:
|
||||
return {
|
||||
6: self.catalog_artifact_url_6,
|
||||
7: self.catalog_artifact_url_7,
|
||||
8: self.catalog_artifact_url_8,
|
||||
9: self.catalog_artifact_url_9,
|
||||
}
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
"""Return the immutable process configuration."""
|
||||
|
||||
return Settings()
|
||||
@@ -0,0 +1 @@
|
||||
"""PostgreSQL infrastructure."""
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Apply configured database migrations."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db.migrations import migrate_url
|
||||
|
||||
|
||||
async def run() -> None:
|
||||
settings = get_settings()
|
||||
count = await migrate_url(settings.database_url.get_secret_value())
|
||||
print(f"applied {count} migration(s)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run())
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Checksummed asynchronous PostgreSQL migration runner."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import asyncpg # type: ignore[import-untyped]
|
||||
from asyncpg import Connection
|
||||
|
||||
|
||||
class MigrationError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Migration:
|
||||
version: int
|
||||
name: str
|
||||
sql: str
|
||||
checksum: str
|
||||
|
||||
|
||||
def load_migrations(root: Path | None = None) -> tuple[Migration, ...]:
|
||||
directory = root or Path(__file__).with_name("migrations")
|
||||
migrations = []
|
||||
for path in sorted(directory.glob("[0-9][0-9][0-9]_*.sql")):
|
||||
version = int(path.name.split("_", 1)[0])
|
||||
sql = path.read_text()
|
||||
migrations.append(
|
||||
Migration(version, path.stem, sql, hashlib.sha256(sql.encode()).hexdigest())
|
||||
)
|
||||
return tuple(migrations)
|
||||
|
||||
|
||||
async def migrate(connection: Connection, migrations: tuple[Migration, ...] | None = None) -> int:
|
||||
await connection.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version integer PRIMARY KEY, name text NOT NULL, checksum text NOT NULL,
|
||||
applied_at timestamptz NOT NULL DEFAULT now())"""
|
||||
)
|
||||
applied = {
|
||||
int(row["version"]): str(row["checksum"])
|
||||
for row in await connection.fetch("SELECT version, checksum FROM schema_migrations")
|
||||
}
|
||||
count = 0
|
||||
for migration in migrations or load_migrations():
|
||||
if migration.version in applied:
|
||||
if applied[migration.version] != migration.checksum:
|
||||
raise MigrationError(f"migration {migration.version} checksum mismatch")
|
||||
continue
|
||||
async with connection.transaction():
|
||||
await connection.execute(migration.sql)
|
||||
await connection.execute(
|
||||
"INSERT INTO schema_migrations(version, name, checksum) VALUES($1, $2, $3)",
|
||||
migration.version,
|
||||
migration.name,
|
||||
migration.checksum,
|
||||
)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
async def migrate_url(database_url: str) -> int:
|
||||
connection = await asyncpg.connect(database_url)
|
||||
try:
|
||||
return await migrate(connection)
|
||||
finally:
|
||||
await connection.close()
|
||||
@@ -0,0 +1,325 @@
|
||||
-- oVirt Engine identity, inventory, and generic API object store.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_domains (
|
||||
id uuid PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_users (
|
||||
id uuid PRIMARY KEY,
|
||||
domain_id uuid NOT NULL REFERENCES ov_domains(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
password_hash text NOT NULL,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
principal text NOT NULL DEFAULT '',
|
||||
UNIQUE (domain_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_groups (
|
||||
id uuid PRIMARY KEY,
|
||||
domain_id uuid NOT NULL REFERENCES ov_domains(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
UNIQUE (domain_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_roles (
|
||||
id uuid PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE,
|
||||
description text NOT NULL DEFAULT '',
|
||||
administrative boolean NOT NULL DEFAULT false
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_permissions (
|
||||
id uuid PRIMARY KEY,
|
||||
role_id uuid NOT NULL REFERENCES ov_roles(id) ON DELETE CASCADE,
|
||||
user_id uuid REFERENCES ov_users(id) ON DELETE CASCADE,
|
||||
group_id uuid REFERENCES ov_groups(id) ON DELETE CASCADE,
|
||||
object_type text NOT NULL DEFAULT 'system',
|
||||
object_id uuid,
|
||||
CHECK (user_id IS NOT NULL OR group_id IS NOT NULL)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_tokens (
|
||||
id text PRIMARY KEY,
|
||||
user_id uuid NOT NULL REFERENCES ov_users(id) ON DELETE CASCADE,
|
||||
scope text NOT NULL DEFAULT 'ovirt-app-api',
|
||||
expires_at timestamptz NOT NULL,
|
||||
issued_at timestamptz NOT NULL DEFAULT now(),
|
||||
revoked boolean NOT NULL DEFAULT false
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ov_tokens_user_idx ON ov_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS ov_tokens_expires_idx ON ov_tokens(expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_datacenters (
|
||||
id uuid PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE,
|
||||
description text NOT NULL DEFAULT '',
|
||||
local boolean NOT NULL DEFAULT false,
|
||||
status text NOT NULL DEFAULT 'up',
|
||||
version_major integer NOT NULL DEFAULT 4,
|
||||
version_minor integer NOT NULL DEFAULT 5,
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_clusters (
|
||||
id uuid PRIMARY KEY,
|
||||
datacenter_id uuid NOT NULL REFERENCES ov_datacenters(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
cpu_type text NOT NULL DEFAULT 'Intel Conroe Family',
|
||||
version_major integer NOT NULL DEFAULT 4,
|
||||
version_minor integer NOT NULL DEFAULT 5,
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
UNIQUE (datacenter_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_hosts (
|
||||
id uuid PRIMARY KEY,
|
||||
cluster_id uuid NOT NULL REFERENCES ov_clusters(id) ON DELETE CASCADE,
|
||||
name text NOT NULL UNIQUE,
|
||||
address text NOT NULL DEFAULT '',
|
||||
status text NOT NULL DEFAULT 'up',
|
||||
type text NOT NULL DEFAULT 'rhel',
|
||||
memory bigint NOT NULL DEFAULT 0,
|
||||
cpu_cores integer NOT NULL DEFAULT 8,
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_networks (
|
||||
id uuid PRIMARY KEY,
|
||||
datacenter_id uuid NOT NULL REFERENCES ov_datacenters(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
vlan_id integer,
|
||||
stp boolean NOT NULL DEFAULT false,
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
UNIQUE (datacenter_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_vnic_profiles (
|
||||
id uuid PRIMARY KEY,
|
||||
network_id uuid NOT NULL REFERENCES ov_networks(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
pass_through boolean NOT NULL DEFAULT false,
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
UNIQUE (network_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_storage_domains (
|
||||
id uuid PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE,
|
||||
type text NOT NULL DEFAULT 'data',
|
||||
storage_type text NOT NULL DEFAULT 'nfs',
|
||||
status text NOT NULL DEFAULT 'active',
|
||||
available bigint NOT NULL DEFAULT 0,
|
||||
used bigint NOT NULL DEFAULT 0,
|
||||
committed bigint NOT NULL DEFAULT 0,
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_storage_domain_attachments (
|
||||
id uuid PRIMARY KEY,
|
||||
storage_domain_id uuid NOT NULL REFERENCES ov_storage_domains(id) ON DELETE CASCADE,
|
||||
datacenter_id uuid NOT NULL REFERENCES ov_datacenters(id) ON DELETE CASCADE,
|
||||
status text NOT NULL DEFAULT 'active',
|
||||
UNIQUE (storage_domain_id, datacenter_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_storage_connections (
|
||||
id uuid PRIMARY KEY,
|
||||
type text NOT NULL DEFAULT 'nfs',
|
||||
address text NOT NULL DEFAULT '',
|
||||
path text NOT NULL DEFAULT '',
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_templates (
|
||||
id uuid PRIMARY KEY,
|
||||
cluster_id uuid REFERENCES ov_clusters(id) ON DELETE SET NULL,
|
||||
name text NOT NULL UNIQUE,
|
||||
description text NOT NULL DEFAULT '',
|
||||
status text NOT NULL DEFAULT 'ok',
|
||||
memory bigint NOT NULL DEFAULT 1073741824,
|
||||
cpu_sockets integer NOT NULL DEFAULT 1,
|
||||
cpu_cores integer NOT NULL DEFAULT 1,
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_vms (
|
||||
id uuid PRIMARY KEY,
|
||||
cluster_id uuid NOT NULL REFERENCES ov_clusters(id) ON DELETE CASCADE,
|
||||
template_id uuid REFERENCES ov_templates(id) ON DELETE SET NULL,
|
||||
name text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
status text NOT NULL DEFAULT 'down',
|
||||
memory bigint NOT NULL DEFAULT 1073741824,
|
||||
cpu_sockets integer NOT NULL DEFAULT 1,
|
||||
cpu_cores integer NOT NULL DEFAULT 1,
|
||||
cpu_threads integer NOT NULL DEFAULT 1,
|
||||
os_type text NOT NULL DEFAULT 'other',
|
||||
type text NOT NULL DEFAULT 'server',
|
||||
host_id uuid REFERENCES ov_hosts(id) ON DELETE SET NULL,
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (cluster_id, name)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ov_vms_status_idx ON ov_vms(status);
|
||||
CREATE INDEX IF NOT EXISTS ov_vms_name_idx ON ov_vms(name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_disks (
|
||||
id uuid PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
status text NOT NULL DEFAULT 'ok',
|
||||
provisioned_size bigint NOT NULL DEFAULT 0,
|
||||
actual_size bigint NOT NULL DEFAULT 0,
|
||||
format text NOT NULL DEFAULT 'cow',
|
||||
sparse boolean NOT NULL DEFAULT true,
|
||||
shareable boolean NOT NULL DEFAULT false,
|
||||
wipe_after_delete boolean NOT NULL DEFAULT false,
|
||||
storage_domain_id uuid REFERENCES ov_storage_domains(id) ON DELETE SET NULL,
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_disk_attachments (
|
||||
id uuid PRIMARY KEY,
|
||||
vm_id uuid NOT NULL REFERENCES ov_vms(id) ON DELETE CASCADE,
|
||||
disk_id uuid NOT NULL REFERENCES ov_disks(id) ON DELETE CASCADE,
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
bootable boolean NOT NULL DEFAULT false,
|
||||
interface text NOT NULL DEFAULT 'virtio_scsi',
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
UNIQUE (vm_id, disk_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_nics (
|
||||
id uuid PRIMARY KEY,
|
||||
vm_id uuid NOT NULL REFERENCES ov_vms(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
interface text NOT NULL DEFAULT 'virtio',
|
||||
linked boolean NOT NULL DEFAULT true,
|
||||
plugged boolean NOT NULL DEFAULT true,
|
||||
mac_address text NOT NULL DEFAULT '',
|
||||
vnic_profile_id uuid REFERENCES ov_vnic_profiles(id) ON DELETE SET NULL,
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
UNIQUE (vm_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_snapshots (
|
||||
id uuid PRIMARY KEY,
|
||||
vm_id uuid NOT NULL REFERENCES ov_vms(id) ON DELETE CASCADE,
|
||||
description text NOT NULL DEFAULT '',
|
||||
status text NOT NULL DEFAULT 'ok',
|
||||
snapshot_type text NOT NULL DEFAULT 'user',
|
||||
persist_memorystate boolean NOT NULL DEFAULT false,
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_tags (
|
||||
id uuid PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE,
|
||||
description text NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_tag_assignments (
|
||||
id uuid PRIMARY KEY,
|
||||
tag_id uuid NOT NULL REFERENCES ov_tags(id) ON DELETE CASCADE,
|
||||
object_type text NOT NULL,
|
||||
object_id uuid NOT NULL,
|
||||
UNIQUE (tag_id, object_type, object_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_affinity_groups (
|
||||
id uuid PRIMARY KEY,
|
||||
cluster_id uuid NOT NULL REFERENCES ov_clusters(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
enforcing boolean NOT NULL DEFAULT true,
|
||||
positive boolean NOT NULL DEFAULT true,
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
UNIQUE (cluster_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_quotas (
|
||||
id uuid PRIMARY KEY,
|
||||
datacenter_id uuid NOT NULL REFERENCES ov_datacenters(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
UNIQUE (datacenter_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_bookmarks (
|
||||
id uuid PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE,
|
||||
value text NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_events (
|
||||
id bigserial PRIMARY KEY,
|
||||
code integer NOT NULL DEFAULT 0,
|
||||
severity text NOT NULL DEFAULT 'normal',
|
||||
description text NOT NULL DEFAULT '',
|
||||
time timestamptz NOT NULL DEFAULT now(),
|
||||
user_id uuid REFERENCES ov_users(id) ON DELETE SET NULL,
|
||||
vm_id uuid REFERENCES ov_vms(id) ON DELETE SET NULL,
|
||||
host_id uuid REFERENCES ov_hosts(id) ON DELETE SET NULL,
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_jobs (
|
||||
id uuid PRIMARY KEY,
|
||||
description text NOT NULL DEFAULT '',
|
||||
status text NOT NULL DEFAULT 'started',
|
||||
auto_cleared boolean NOT NULL DEFAULT true,
|
||||
started timestamptz NOT NULL DEFAULT now(),
|
||||
ended timestamptz,
|
||||
owner_id uuid REFERENCES ov_users(id) ON DELETE SET NULL,
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_job_steps (
|
||||
id uuid PRIMARY KEY,
|
||||
job_id uuid NOT NULL REFERENCES ov_jobs(id) ON DELETE CASCADE,
|
||||
description text NOT NULL DEFAULT '',
|
||||
status text NOT NULL DEFAULT 'started',
|
||||
type text NOT NULL DEFAULT 'validating',
|
||||
number integer NOT NULL DEFAULT 1,
|
||||
started timestamptz NOT NULL DEFAULT now(),
|
||||
ended timestamptz,
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
-- Generic store for surface-complete collections not given dedicated tables.
|
||||
CREATE TABLE IF NOT EXISTS ov_api_objects (
|
||||
id uuid PRIMARY KEY,
|
||||
collection text NOT NULL,
|
||||
name text NOT NULL DEFAULT '',
|
||||
status text NOT NULL DEFAULT 'ok',
|
||||
parent_collection text,
|
||||
parent_id uuid,
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ov_api_objects_collection_idx ON ov_api_objects(collection);
|
||||
CREATE INDEX IF NOT EXISTS ov_api_objects_parent_idx ON ov_api_objects(parent_collection, parent_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_demo_meta (
|
||||
key text PRIMARY KEY,
|
||||
value text NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ov_runtime_meta (
|
||||
key text PRIMARY KEY,
|
||||
value text NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Small typed asyncpg pool boundary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, Self, cast
|
||||
|
||||
import asyncpg # type: ignore[import-untyped]
|
||||
from asyncpg import Pool
|
||||
|
||||
from app.config import Settings
|
||||
from app.db.migrations import load_migrations
|
||||
|
||||
LATEST_SCHEMA_VERSION = max(migration.version for migration in load_migrations())
|
||||
|
||||
|
||||
class Database(Protocol):
|
||||
"""Application-facing database lifecycle and health interface."""
|
||||
|
||||
async def connect(self) -> None: ...
|
||||
|
||||
async def close(self) -> None: ...
|
||||
|
||||
async def is_ready(self) -> bool: ...
|
||||
|
||||
|
||||
class AsyncpgDatabase:
|
||||
"""Own an asyncpg pool without exposing it as global mutable state."""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._settings = settings
|
||||
self._pool: Pool | None = None
|
||||
|
||||
@property
|
||||
def pool(self) -> Pool:
|
||||
"""Return the initialized pool to repository factories."""
|
||||
|
||||
if self._pool is None:
|
||||
message = "database pool is not initialized"
|
||||
raise RuntimeError(message)
|
||||
return self._pool
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Create the pool and verify the first connection."""
|
||||
|
||||
if self._pool is not None:
|
||||
return
|
||||
settings = self._settings
|
||||
pool = await asyncpg.create_pool(
|
||||
dsn=settings.database_url.get_secret_value(),
|
||||
min_size=settings.db_pool_min_size,
|
||||
max_size=settings.db_pool_max_size,
|
||||
timeout=settings.db_connect_timeout_seconds,
|
||||
command_timeout=settings.db_command_timeout_seconds,
|
||||
)
|
||||
if pool is None: # pragma: no cover - asyncpg types allow this for legacy reasons
|
||||
message = "asyncpg did not create a pool"
|
||||
raise RuntimeError(message)
|
||||
self._pool = cast(Pool, pool)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close all pooled connections; repeated close is safe."""
|
||||
|
||||
pool, self._pool = self._pool, None
|
||||
if pool is not None:
|
||||
await pool.close()
|
||||
|
||||
async def is_ready(self) -> bool:
|
||||
"""Check connectivity and that all packaged migrations are applied."""
|
||||
|
||||
if self._pool is None:
|
||||
return False
|
||||
try:
|
||||
return bool(
|
||||
await self._pool.fetchval(
|
||||
"""SELECT COALESCE(max(version), 0) >= $1
|
||||
FROM schema_migrations""",
|
||||
LATEST_SCHEMA_VERSION,
|
||||
)
|
||||
)
|
||||
except asyncpg.PostgresError:
|
||||
return False
|
||||
|
||||
async def __aenter__(self) -> Self:
|
||||
await self.connect()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: object, exc: object, traceback: object) -> None:
|
||||
await self.close()
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Typed transactional helpers and stable database error mapping."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
|
||||
import asyncpg # type: ignore[import-untyped]
|
||||
from asyncpg import Connection, Pool
|
||||
|
||||
|
||||
class DatabaseOperationError(RuntimeError):
|
||||
"""Safe base error for repository operations."""
|
||||
|
||||
|
||||
class ConflictError(DatabaseOperationError):
|
||||
pass
|
||||
|
||||
|
||||
class ReferenceError(DatabaseOperationError):
|
||||
pass
|
||||
|
||||
|
||||
class TransientDatabaseError(DatabaseOperationError):
|
||||
pass
|
||||
|
||||
|
||||
def map_database_error(error: asyncpg.PostgresError) -> DatabaseOperationError:
|
||||
if isinstance(error, asyncpg.UniqueViolationError):
|
||||
return ConflictError("database uniqueness constraint failed")
|
||||
if isinstance(error, asyncpg.ForeignKeyViolationError):
|
||||
return ReferenceError("database reference constraint failed")
|
||||
if isinstance(
|
||||
error,
|
||||
asyncpg.SerializationError
|
||||
| asyncpg.DeadlockDetectedError
|
||||
| asyncpg.TooManyConnectionsError,
|
||||
):
|
||||
return TransientDatabaseError("transient database failure")
|
||||
return DatabaseOperationError("database operation failed")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def transaction(pool: Pool) -> AsyncIterator[Connection]:
|
||||
async with pool.acquire() as connection:
|
||||
try:
|
||||
async with connection.transaction():
|
||||
yield connection
|
||||
except asyncpg.PostgresError as error:
|
||||
raise map_database_error(error) from error
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def savepoint(connection: Connection) -> AsyncIterator[Connection]:
|
||||
try:
|
||||
async with connection.transaction():
|
||||
yield connection
|
||||
except asyncpg.PostgresError as error:
|
||||
raise map_database_error(error) from error
|
||||
|
||||
|
||||
def require_affected(status: str, expected: int = 1) -> None:
|
||||
try:
|
||||
affected = int(status.rsplit(" ", 1)[1])
|
||||
except (IndexError, ValueError) as error:
|
||||
raise DatabaseOperationError(f"unrecognized command status: {status}") from error
|
||||
if affected != expected:
|
||||
raise DatabaseOperationError(f"expected {expected} affected row(s), got {affected}")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RetryPolicy:
|
||||
attempts: int = 3
|
||||
base_delay_seconds: float = 0.02
|
||||
|
||||
|
||||
DEFAULT_RETRY_POLICY = RetryPolicy()
|
||||
|
||||
|
||||
async def retry_transient[T](
|
||||
operation: Callable[[], Awaitable[T]], policy: RetryPolicy = DEFAULT_RETRY_POLICY
|
||||
) -> T:
|
||||
if policy.attempts < 1:
|
||||
raise ValueError("retry attempts must be positive")
|
||||
for attempt in range(policy.attempts):
|
||||
try:
|
||||
return await operation()
|
||||
except TransientDatabaseError:
|
||||
if attempt + 1 == policy.attempts:
|
||||
raise
|
||||
await asyncio.sleep(policy.base_delay_seconds * (2**attempt))
|
||||
raise RuntimeError("unreachable retry state")
|
||||
@@ -0,0 +1 @@
|
||||
"""Typed PostgreSQL repositories for simulation domain state."""
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Typed resource persistence with explicit optimistic locking."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, cast
|
||||
|
||||
from asyncpg import Pool # type: ignore[import-untyped]
|
||||
|
||||
from app.db.primitives import ConflictError, transaction
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ResourceRecord:
|
||||
id: uuid.UUID
|
||||
cluster_id: uuid.UUID
|
||||
node: str
|
||||
kind: str
|
||||
external_id: str
|
||||
state: dict[str, Any]
|
||||
metadata: dict[str, Any]
|
||||
version: int
|
||||
|
||||
|
||||
def _json_object(value: object) -> dict[str, Any]:
|
||||
if isinstance(value, str):
|
||||
return cast(dict[str, Any], json.loads(value))
|
||||
return dict(cast(Mapping[str, Any], value))
|
||||
|
||||
|
||||
def _record(row: Mapping[str, object]) -> ResourceRecord:
|
||||
return ResourceRecord(
|
||||
id=cast(uuid.UUID, row["id"]),
|
||||
cluster_id=cast(uuid.UUID, row["cluster_id"]),
|
||||
node=str(row["node"]),
|
||||
kind=str(row["kind"]),
|
||||
external_id=str(row["external_id"]),
|
||||
state=_json_object(row["state"]),
|
||||
metadata=_json_object(row["metadata"]),
|
||||
version=int(cast(int, row["version"])),
|
||||
)
|
||||
|
||||
|
||||
class ResourceRepository:
|
||||
def __init__(self, pool: Pool) -> None:
|
||||
self._pool = pool
|
||||
|
||||
async def list(
|
||||
self, *, kind: str | None = None, node: str | None = None
|
||||
) -> list[ResourceRecord]:
|
||||
rows = await self._pool.fetch(
|
||||
"""SELECT r.id, r.cluster_id, n.name AS node, r.kind, r.external_id,
|
||||
r.state, r.metadata, r.version
|
||||
FROM resources r JOIN nodes n ON n.id=r.node_id
|
||||
WHERE ($1::text IS NULL OR r.kind=$1)
|
||||
AND ($2::text IS NULL OR n.name=$2)
|
||||
ORDER BY r.kind, r.external_id""",
|
||||
kind,
|
||||
node,
|
||||
)
|
||||
return [_record(row) for row in rows]
|
||||
|
||||
async def get(self, *, kind: str, external_id: str) -> ResourceRecord | None:
|
||||
row = await self._pool.fetchrow(
|
||||
"""SELECT r.id, r.cluster_id, n.name AS node, r.kind, r.external_id,
|
||||
r.state, r.metadata, r.version
|
||||
FROM resources r JOIN nodes n ON n.id=r.node_id
|
||||
WHERE r.kind=$1 AND r.external_id=$2""",
|
||||
kind,
|
||||
external_id,
|
||||
)
|
||||
return None if row is None else _record(row)
|
||||
|
||||
async def update_state(
|
||||
self,
|
||||
resource_id: uuid.UUID,
|
||||
*,
|
||||
expected_version: int,
|
||||
state: Mapping[str, object],
|
||||
) -> ResourceRecord:
|
||||
async with transaction(self._pool) as connection:
|
||||
row = await connection.fetchrow(
|
||||
"""UPDATE resources SET state=$3::jsonb, version=version+1,
|
||||
updated_at=now() WHERE id=$1 AND version=$2
|
||||
RETURNING id, cluster_id,
|
||||
(SELECT name FROM nodes WHERE id=resources.node_id) AS node,
|
||||
kind, external_id, state, metadata, version""",
|
||||
resource_id,
|
||||
expected_version,
|
||||
json.dumps(dict(state), sort_keys=True),
|
||||
)
|
||||
if row is None:
|
||||
raise ConflictError("resource version conflict or resource missing")
|
||||
return _record(row)
|
||||
@@ -0,0 +1,14 @@
|
||||
"""FastAPI dependency adapters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from app.db.pool import Database
|
||||
|
||||
|
||||
def get_database(request: Request) -> Database:
|
||||
"""Resolve the lifespan-owned database from application state."""
|
||||
|
||||
database: Database = request.app.state.database
|
||||
return database
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Application resource ownership."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
||||
from typing import Protocol
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.config import Settings
|
||||
from app.db.pool import AsyncpgDatabase, Database
|
||||
|
||||
DatabaseFactory = Callable[[Settings], Database]
|
||||
Lifespan = Callable[[FastAPI], AbstractAsyncContextManager[None]]
|
||||
|
||||
|
||||
class LifespanWorker(Protocol):
|
||||
async def run(self) -> None: ...
|
||||
|
||||
def stop(self) -> None: ...
|
||||
|
||||
|
||||
WorkerFactory = Callable[[Database], LifespanWorker]
|
||||
|
||||
|
||||
def create_lifespan(
|
||||
settings: Settings,
|
||||
database_factory: DatabaseFactory,
|
||||
worker_factories: tuple[WorkerFactory, ...] = (),
|
||||
) -> Lifespan:
|
||||
"""Build a lifespan context so tests can inject a database implementation."""
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
database = database_factory(settings)
|
||||
await database.connect()
|
||||
app.state.database = database
|
||||
if isinstance(database, AsyncpgDatabase):
|
||||
from app.ovirt.demo_datacenter import DEMO_PROFILE
|
||||
from app.ovirt.seed import seed_ovirt
|
||||
from app.ovirt.settings import seed_engine_options
|
||||
|
||||
async with database.pool.acquire() as connection:
|
||||
try:
|
||||
profile = await connection.fetchval(
|
||||
"SELECT value FROM ov_demo_meta WHERE key = 'profile'"
|
||||
)
|
||||
except Exception:
|
||||
profile = None
|
||||
if profile != DEMO_PROFILE:
|
||||
await seed_ovirt(connection)
|
||||
else:
|
||||
# Keep Engine options current without wiping demo inventory.
|
||||
await seed_engine_options(connection)
|
||||
workers = tuple(factory(database) for factory in worker_factories)
|
||||
worker_tasks = tuple(asyncio.create_task(worker.run()) for worker in workers)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for worker in workers:
|
||||
worker.stop()
|
||||
if worker_tasks:
|
||||
await asyncio.gather(*worker_tasks)
|
||||
await database.close()
|
||||
|
||||
return lifespan
|
||||
|
||||
|
||||
def default_database_factory(settings: Settings) -> Database:
|
||||
"""Create the production asyncpg adapter."""
|
||||
|
||||
return AsyncpgDatabase(settings)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Structured logging configuration with safe JSON output."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
class JsonFormatter(logging.Formatter):
|
||||
"""Serialize standard records and selected structured attributes as JSON."""
|
||||
|
||||
_fields = ("request_id", "method", "path", "status", "duration_ms")
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
payload: dict[str, Any] = {
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"message": record.getMessage(),
|
||||
}
|
||||
for field in self._fields:
|
||||
value = getattr(record, field, None)
|
||||
if value is not None:
|
||||
payload[field] = value
|
||||
if record.exc_info is not None:
|
||||
payload["exception"] = self.formatException(record.exc_info)
|
||||
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def configure_logging(level: str) -> None:
|
||||
"""Configure the root logger once for the process."""
|
||||
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(JsonFormatter())
|
||||
root = logging.getLogger()
|
||||
root.handlers.clear()
|
||||
root.addHandler(handler)
|
||||
root.setLevel(level.upper())
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
"""FastAPI application factory and ASGI entry point."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.api.errors import ApiError, api_error_handler, unhandled_exception_handler
|
||||
from app.api.middleware import RequestContextMiddleware
|
||||
from app.api.openapi import openapi_tag_metadata
|
||||
from app.config import Settings, get_settings
|
||||
from app.lifespan import DatabaseFactory, create_lifespan, default_database_factory
|
||||
from app.logging import configure_logging
|
||||
from app.observability.health import router as health_router
|
||||
from app.ovirt.mount import mount_ovirt_routes
|
||||
from app.web.routes import router as web_router
|
||||
|
||||
|
||||
def create_app(
|
||||
settings: Settings | None = None,
|
||||
database_factory: DatabaseFactory = default_database_factory,
|
||||
) -> FastAPI:
|
||||
"""Create an isolated application instance."""
|
||||
|
||||
resolved = settings or get_settings()
|
||||
configure_logging(resolved.log_level)
|
||||
app = FastAPI(
|
||||
title=resolved.app_name,
|
||||
version="0.1.0",
|
||||
openapi_tags=openapi_tag_metadata(),
|
||||
lifespan=create_lifespan(resolved, database_factory, ()),
|
||||
)
|
||||
app.state.settings = resolved
|
||||
app.state.contract_swap_lock = asyncio.Lock()
|
||||
app.add_middleware(RequestContextMiddleware, header_name=resolved.request_id_header)
|
||||
app.add_exception_handler(Exception, unhandled_exception_handler)
|
||||
app.add_exception_handler(ApiError, api_error_handler)
|
||||
app.include_router(web_router)
|
||||
app.include_router(health_router)
|
||||
mount_ovirt_routes(app, series=os.environ.get("OVIRT_SERIES", "4.5"))
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1 @@
|
||||
"""Health, metrics, and tracing adapters."""
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Kubernetes-compatible health endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Response, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.db.pool import Database
|
||||
from app.dependencies import get_database
|
||||
|
||||
router = APIRouter(prefix="/health", tags=["Simulator"])
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
@router.get("/live", response_model=HealthResponse)
|
||||
async def live() -> HealthResponse:
|
||||
"""Report process liveness without checking dependencies."""
|
||||
|
||||
return HealthResponse(status="ok")
|
||||
|
||||
|
||||
@router.get("/ready", response_model=HealthResponse)
|
||||
async def ready(
|
||||
response: Response,
|
||||
database: Annotated[Database, Depends(get_database)],
|
||||
) -> HealthResponse:
|
||||
"""Report whether the required database dependency is usable."""
|
||||
|
||||
if await database.is_ready():
|
||||
return HealthResponse(status="ok")
|
||||
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
return HealthResponse(status="unavailable")
|
||||
@@ -0,0 +1,9 @@
|
||||
"""oVirt / RHV Engine API simulator domain package."""
|
||||
|
||||
__all__ = ["mount_ovirt_routes"]
|
||||
|
||||
|
||||
def mount_ovirt_routes(*args, **kwargs): # lazy re-export
|
||||
from app.ovirt.mount import mount_ovirt_routes as _mount
|
||||
|
||||
return _mount(*args, **kwargs)
|
||||
@@ -0,0 +1,220 @@
|
||||
"""oVirt Engine SSO OAuth2 + Basic auth + session cookies."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from asyncpg import Connection
|
||||
|
||||
from app.ovirt.errors import OVirtError
|
||||
from app.ovirt.settings import (
|
||||
OPT_BASIC_SESSION_TTL_SECONDS,
|
||||
OPT_DEFAULT_API_SCOPE,
|
||||
OPT_DEFAULT_AUTH_DOMAIN,
|
||||
OPT_DEFAULT_TOKEN_TYPE,
|
||||
OPT_DEFAULT_USER_ROLE,
|
||||
OPT_OAUTH_TOKEN_TTL_SECONDS,
|
||||
option_int,
|
||||
option_value,
|
||||
)
|
||||
from app.security.auth import verify_secret
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthContext:
|
||||
token_id: str
|
||||
user_id: UUID
|
||||
user_name: str
|
||||
domain: str
|
||||
roles: tuple[str, ...]
|
||||
expires_at: datetime
|
||||
is_admin: bool
|
||||
scope: str
|
||||
|
||||
|
||||
async def authenticate_password(
|
||||
conn: Connection,
|
||||
username: str,
|
||||
password: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Resolve user@domain credentials."""
|
||||
|
||||
name, _, domain = username.partition("@")
|
||||
if not domain:
|
||||
domain = await option_value(conn, OPT_DEFAULT_AUTH_DOMAIN)
|
||||
row = await conn.fetchrow(
|
||||
"""SELECT u.id, u.name, u.password_hash, u.enabled, d.name AS domain_name
|
||||
FROM ov_users u
|
||||
JOIN ov_domains d ON d.id = u.domain_id
|
||||
WHERE u.name = $1 AND d.name = $2""",
|
||||
name,
|
||||
domain,
|
||||
)
|
||||
if row is None or not row["enabled"] or not verify_secret(password, row["password_hash"]):
|
||||
raise OVirtError("Unauthorized", "Incorrect credentials", status_code=401)
|
||||
return dict(row)
|
||||
|
||||
|
||||
async def issue_oauth_token(
|
||||
conn: Connection,
|
||||
*,
|
||||
username: str,
|
||||
password: str,
|
||||
scope: str | None = None,
|
||||
ttl_seconds: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
default_scope = await option_value(conn, OPT_DEFAULT_API_SCOPE)
|
||||
token_type = await option_value(conn, OPT_DEFAULT_TOKEN_TYPE)
|
||||
if ttl_seconds is None:
|
||||
ttl_seconds = await option_int(conn, OPT_OAUTH_TOKEN_TTL_SECONDS)
|
||||
scope = scope or default_scope
|
||||
if scope and default_scope not in scope.split():
|
||||
raise OVirtError("Unauthorized", "Invalid scope", status_code=400)
|
||||
user = await authenticate_password(conn, username, password)
|
||||
token = secrets.token_urlsafe(32)
|
||||
now = datetime.now(UTC)
|
||||
expires = now + timedelta(seconds=ttl_seconds)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_tokens(id, user_id, scope, expires_at, issued_at, revoked)
|
||||
VALUES($1, $2, $3, $4, $5, false)""",
|
||||
token,
|
||||
user["id"],
|
||||
scope,
|
||||
expires,
|
||||
now,
|
||||
)
|
||||
row = await conn.fetchrow("SELECT * FROM ov_tokens WHERE id=$1", token)
|
||||
return {
|
||||
"access_token": row["id"],
|
||||
"token_type": token_type,
|
||||
"scope": row["scope"],
|
||||
"exp": int(row["expires_at"].timestamp()),
|
||||
}
|
||||
|
||||
|
||||
async def validate_bearer(conn: Connection, token: str) -> AuthContext:
|
||||
if not token:
|
||||
raise OVirtError("Unauthorized", "Authentication required", status_code=401)
|
||||
row = await conn.fetchrow(
|
||||
"""SELECT t.id, t.user_id, t.expires_at, t.revoked, t.scope,
|
||||
u.name AS user_name, d.name AS domain_name
|
||||
FROM ov_tokens t
|
||||
JOIN ov_users u ON u.id = t.user_id
|
||||
JOIN ov_domains d ON d.id = u.domain_id
|
||||
WHERE t.id = $1""",
|
||||
token,
|
||||
)
|
||||
if row is None or row["revoked"]:
|
||||
raise OVirtError("Unauthorized", "Invalid token", status_code=401)
|
||||
expires = row["expires_at"]
|
||||
if expires.tzinfo is None:
|
||||
expires = expires.replace(tzinfo=UTC)
|
||||
if expires <= datetime.now(UTC):
|
||||
raise OVirtError("Unauthorized", "Token has expired", status_code=401)
|
||||
roles = await _roles_for_user(conn, row["user_id"])
|
||||
return AuthContext(
|
||||
token_id=str(row["id"]),
|
||||
user_id=row["user_id"],
|
||||
user_name=str(row["user_name"]),
|
||||
domain=str(row["domain_name"]),
|
||||
roles=roles,
|
||||
expires_at=expires,
|
||||
is_admin="SuperUser" in roles or "admin" in roles,
|
||||
scope=str(row["scope"] or ""),
|
||||
)
|
||||
|
||||
|
||||
async def validate_basic(conn: Connection, header_value: str) -> AuthContext:
|
||||
try:
|
||||
encoded = header_value.split(" ", 1)[1].strip()
|
||||
decoded = base64.b64decode(encoded).decode("utf-8")
|
||||
username, _, password = decoded.partition(":")
|
||||
except Exception as exc:
|
||||
raise OVirtError("Unauthorized", "Malformed basic auth", status_code=401) from exc
|
||||
user = await authenticate_password(conn, username, password)
|
||||
token = secrets.token_urlsafe(24)
|
||||
now = datetime.now(UTC)
|
||||
ttl = await option_int(conn, OPT_BASIC_SESSION_TTL_SECONDS)
|
||||
default_scope = await option_value(conn, OPT_DEFAULT_API_SCOPE)
|
||||
expires = now + timedelta(seconds=ttl)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_tokens(id, user_id, scope, expires_at, issued_at, revoked)
|
||||
VALUES($1, $2, $3, $4, $5, false)
|
||||
ON CONFLICT (id) DO NOTHING""",
|
||||
token,
|
||||
user["id"],
|
||||
default_scope,
|
||||
expires,
|
||||
now,
|
||||
)
|
||||
row = await conn.fetchrow("SELECT * FROM ov_tokens WHERE id=$1", token)
|
||||
roles = await _roles_for_user(conn, user["id"])
|
||||
return AuthContext(
|
||||
token_id=str(row["id"]),
|
||||
user_id=user["id"],
|
||||
user_name=str(user["name"]),
|
||||
domain=str(user["domain_name"]),
|
||||
roles=roles,
|
||||
expires_at=row["expires_at"] if row["expires_at"].tzinfo else row["expires_at"].replace(tzinfo=UTC),
|
||||
is_admin="SuperUser" in roles or "admin" in roles,
|
||||
scope=str(row["scope"] or default_scope),
|
||||
)
|
||||
|
||||
|
||||
async def _roles_for_user(conn: Connection, user_id: UUID) -> tuple[str, ...]:
|
||||
rows = await conn.fetch(
|
||||
"""SELECT r.name FROM ov_permissions p
|
||||
JOIN ov_roles r ON r.id = p.role_id
|
||||
WHERE p.user_id = $1""",
|
||||
user_id,
|
||||
)
|
||||
names = [str(r["name"]) for r in rows]
|
||||
if not names:
|
||||
default_role = await option_value(conn, OPT_DEFAULT_USER_ROLE)
|
||||
exists = await conn.fetchval("SELECT 1 FROM ov_roles WHERE name=$1", default_role)
|
||||
if exists:
|
||||
names = [default_role]
|
||||
return tuple(names)
|
||||
|
||||
|
||||
def extract_auth(headers: dict[str, str]) -> tuple[str, str] | None:
|
||||
"""Return ('bearer'|'basic'|'session', credential) or None."""
|
||||
|
||||
lower = {k.lower(): v for k, v in headers.items()}
|
||||
auth = lower.get("authorization", "")
|
||||
if auth.lower().startswith("bearer "):
|
||||
return "bearer", auth.split(" ", 1)[1].strip()
|
||||
if auth.lower().startswith("basic "):
|
||||
return "basic", auth
|
||||
session = lower.get("prefer") or ""
|
||||
if "jsessionid" in lower:
|
||||
return "session", lower["jsessionid"]
|
||||
cookie = lower.get("cookie", "")
|
||||
for part in cookie.split(";"):
|
||||
part = part.strip()
|
||||
if part.lower().startswith("jsessionid="):
|
||||
return "session", part.split("=", 1)[1]
|
||||
if part.lower().startswith("ovirt_token="):
|
||||
return "bearer", part.split("=", 1)[1]
|
||||
if session.lower().startswith("persistent-auth"):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
async def resolve_request_auth(conn: Connection, headers: dict[str, str]) -> AuthContext:
|
||||
kind = extract_auth(headers)
|
||||
if kind is None:
|
||||
raise OVirtError("Unauthorized", "Authentication required", status_code=401)
|
||||
mode, credential = kind
|
||||
if mode == "bearer":
|
||||
return await validate_bearer(conn, credential)
|
||||
if mode == "basic":
|
||||
return await validate_basic(conn, credential)
|
||||
if mode == "session":
|
||||
return await validate_bearer(conn, credential)
|
||||
raise OVirtError("Unauthorized", "Authentication required", status_code=401)
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Resolve oVirt Engine series contract pack locations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.ovirt.opspec import OperationSpec, SeriesPack
|
||||
|
||||
_SERIES_MAJOR = {
|
||||
"3.0": 30,
|
||||
"3.1": 31,
|
||||
"3.2": 32,
|
||||
"3.3": 33,
|
||||
"3.4": 34,
|
||||
"3.5": 35,
|
||||
"3.6": 36,
|
||||
"4.3": 43,
|
||||
"4.4": 44,
|
||||
"4.5": 45,
|
||||
"master": 50,
|
||||
}
|
||||
_MAJOR_SERIES = {v: k for k, v in _SERIES_MAJOR.items()}
|
||||
|
||||
|
||||
def contracts_root() -> Path:
|
||||
"""Locate ``contracts/ovirt`` for source, wheel, or Docker layouts."""
|
||||
|
||||
env = os.environ.get("OVIRT_CONTRACTS_ROOT")
|
||||
if env:
|
||||
return Path(env)
|
||||
|
||||
here = Path(__file__).resolve()
|
||||
candidates = [
|
||||
here.parents[2] / "contracts" / "ovirt", # repo checkout or site-packages
|
||||
Path("/app/contracts/ovirt"), # runtime image WORKDIR layout
|
||||
Path.cwd() / "contracts" / "ovirt",
|
||||
]
|
||||
for path in candidates:
|
||||
if path.is_dir():
|
||||
return path
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def series_for_major(major: int) -> str:
|
||||
return _MAJOR_SERIES.get(major, "4.5")
|
||||
|
||||
|
||||
def major_for_series(series: str) -> int:
|
||||
return _SERIES_MAJOR.get(series.lower(), 45)
|
||||
|
||||
|
||||
def list_series() -> list[dict[str, Any]]:
|
||||
root = contracts_root()
|
||||
if not root.exists():
|
||||
return []
|
||||
result: list[dict[str, Any]] = []
|
||||
for path in sorted(root.iterdir()):
|
||||
man = path / "manifest.json"
|
||||
if not man.is_file():
|
||||
continue
|
||||
data = json.loads(man.read_text())
|
||||
result.append(
|
||||
{
|
||||
"series": data.get("series", path.name),
|
||||
"major": data.get("major", major_for_series(path.name)),
|
||||
"api_version": data.get("api_version", "4"),
|
||||
"operation_count": data.get("operation_count", 0),
|
||||
"service_count": data.get("service_count", 1),
|
||||
"checksum": data.get("checksum", ""),
|
||||
"generated_at": data.get("generated_at", ""),
|
||||
"deltas": data.get("deltas", {}),
|
||||
"product": data.get("product", {}),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _op_from_dict(raw: dict[str, Any]) -> OperationSpec:
|
||||
return OperationSpec(
|
||||
operation_id=str(raw["operation_id"]),
|
||||
method=raw["method"],
|
||||
path=str(raw["path"]),
|
||||
resource_type=str(raw.get("resource_type") or "object"),
|
||||
collection_key=str(raw.get("collection_key") or "objects"),
|
||||
element=str(raw.get("element") or raw.get("resource_type") or "object"),
|
||||
kind=str(raw.get("kind") or "collection"),
|
||||
search=bool(raw.get("search", False)),
|
||||
introduced_in=str(raw.get("introduced_in") or "3.0"),
|
||||
requires_auth=bool(raw.get("requires_auth", True)),
|
||||
status_code=int(raw.get("status_code") or 200),
|
||||
create_status=int(raw.get("create_status") or 201),
|
||||
notes=str(raw.get("notes") or ""),
|
||||
response_fixture=raw.get("response_fixture"),
|
||||
)
|
||||
|
||||
|
||||
def load_series_pack(series: str) -> SeriesPack:
|
||||
series = series.lower()
|
||||
series_dir = contracts_root() / series
|
||||
man_path = series_dir / "manifest.json"
|
||||
api_path = series_dir / "api.json"
|
||||
if not man_path.is_file() or not api_path.is_file():
|
||||
raise FileNotFoundError(f"oVirt contract pack not found: {series_dir}")
|
||||
man = json.loads(man_path.read_text())
|
||||
data = json.loads(api_path.read_text())
|
||||
ops = [_op_from_dict(raw) for raw in data.get("operations") or []]
|
||||
return SeriesPack(
|
||||
series=str(man.get("series", series)),
|
||||
api_version=str(man.get("api_version") or data.get("api_version") or "4"),
|
||||
major=int(man.get("major") or major_for_series(series)),
|
||||
operations=ops,
|
||||
product=dict(man.get("product") or data.get("product") or {}),
|
||||
entry_point_links=list(man.get("entry_point_links") or []),
|
||||
checksum=str(man.get("checksum") or ""),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContractRuntime:
|
||||
series: str = "4.5"
|
||||
pack: SeriesPack | None = None
|
||||
_lock: threading.RLock = field(default_factory=threading.RLock)
|
||||
|
||||
def reload(self, series: str | None = None) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
target = (series or self.series).lower()
|
||||
self.pack = load_series_pack(target)
|
||||
self.series = target
|
||||
return self.summary()
|
||||
|
||||
def summary(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
pack = self.pack
|
||||
if pack is None:
|
||||
return {
|
||||
"series": self.series,
|
||||
"operation_count": 0,
|
||||
"major": major_for_series(self.series),
|
||||
}
|
||||
return {
|
||||
"series": pack.series,
|
||||
"major": pack.major,
|
||||
"api_version": pack.api_version,
|
||||
"operation_count": pack.operation_count(),
|
||||
"service_count": 1,
|
||||
"checksum": pack.checksum,
|
||||
"product": pack.product,
|
||||
"deltas": next(
|
||||
(s.get("deltas") for s in list_series() if s["series"] == pack.series),
|
||||
{},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
_RUNTIME = ContractRuntime()
|
||||
|
||||
|
||||
def get_runtime() -> ContractRuntime:
|
||||
return _RUNTIME
|
||||
|
||||
|
||||
def ensure_loaded(series: str = "4.5") -> ContractRuntime:
|
||||
rt = get_runtime()
|
||||
if rt.pack is None:
|
||||
try:
|
||||
rt.reload(series)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return rt
|
||||
@@ -0,0 +1,436 @@
|
||||
"""Large demo datacenter seed (~1000 VMs + full inventory)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from asyncpg import Connection
|
||||
|
||||
from app.ovirt.ids import stable_id
|
||||
from app.ovirt.seed import DEMO_PROFILE, clear_ovirt_state, seed_ovirt
|
||||
from app.security.auth import hash_secret
|
||||
|
||||
DEMO_VM_COUNT = 1000
|
||||
|
||||
|
||||
async def seed_ovirt_demo(conn: Connection) -> dict[str, Any]:
|
||||
"""Replace state with a multi-DC demo inventory including ~1000 VMs."""
|
||||
|
||||
await clear_ovirt_state(conn)
|
||||
|
||||
domain_id = stable_id("domain", "internal")
|
||||
await conn.execute("INSERT INTO ov_domains(id, name) VALUES($1,'internal')", domain_id)
|
||||
pwd = hash_secret("secret", salt=b"ovirt-sim-v1-salt!")
|
||||
role_super = stable_id("role", "SuperUser")
|
||||
role_user = stable_id("role", "UserRole")
|
||||
role_cluster = stable_id("role", "ClusterAdmin")
|
||||
for rid, name, admin in (
|
||||
(role_super, "SuperUser", True),
|
||||
(role_user, "UserRole", False),
|
||||
(role_cluster, "ClusterAdmin", True),
|
||||
(stable_id("role", "TemplateAdmin"), "TemplateAdmin", True),
|
||||
(stable_id("role", "StorageAdmin"), "StorageAdmin", True),
|
||||
):
|
||||
await conn.execute(
|
||||
"INSERT INTO ov_roles(id, name, administrative) VALUES($1,$2,$3)",
|
||||
rid,
|
||||
name,
|
||||
admin,
|
||||
)
|
||||
|
||||
users = {}
|
||||
for uname, role in (
|
||||
("admin", role_super),
|
||||
("ops", role_cluster),
|
||||
("developer", role_user),
|
||||
("demo", role_user),
|
||||
):
|
||||
uid = stable_id("user", uname)
|
||||
users[uname] = uid
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_users(id, domain_id, name, password_hash, enabled, principal)
|
||||
VALUES($1,$2,$3,$4,true,$5)""",
|
||||
uid,
|
||||
domain_id,
|
||||
uname,
|
||||
pwd,
|
||||
f"{uname}@internal",
|
||||
)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_permissions(id, role_id, user_id, object_type)
|
||||
VALUES($1,$2,$3,'system')""",
|
||||
stable_id("perm", uname),
|
||||
role,
|
||||
uid,
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"INSERT INTO ov_groups(id, domain_id, name) VALUES($1,$2,'engine-admins')",
|
||||
stable_id("group", "engine-admins"),
|
||||
domain_id,
|
||||
)
|
||||
for gname in ("developers", "operators", "readers"):
|
||||
await conn.execute(
|
||||
"INSERT INTO ov_groups(id, domain_id, name) VALUES($1,$2,$3)",
|
||||
stable_id("group", gname),
|
||||
domain_id,
|
||||
gname,
|
||||
)
|
||||
|
||||
# 3 datacenters, multiple clusters/hosts/storage/networks
|
||||
dc_specs = [
|
||||
("dc-prod", "Production", False, 4, 5),
|
||||
("dc-stage", "Staging", False, 4, 4),
|
||||
("dc-edge", "Edge", True, 4, 3),
|
||||
]
|
||||
clusters: list[tuple[Any, Any, str]] = []
|
||||
hosts: list[Any] = []
|
||||
networks: list[Any] = []
|
||||
profiles: list[Any] = []
|
||||
storage_domains: list[Any] = []
|
||||
|
||||
storage_types = ["nfs", "iscsi", "fcp", "localfs"]
|
||||
for dc_key, dc_name, local, maj, minor in dc_specs:
|
||||
dc_id = stable_id("dc", dc_key)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_datacenters(id, name, description, local, status, version_major, version_minor)
|
||||
VALUES($1,$2,$3,$4,'up',$5,$6)""",
|
||||
dc_id,
|
||||
dc_name,
|
||||
f"{dc_name} datacenter",
|
||||
local,
|
||||
maj,
|
||||
minor,
|
||||
)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_quotas(id, datacenter_id, name, description)
|
||||
VALUES($1,$2,'Default','Default quota')""",
|
||||
stable_id("quota", dc_key),
|
||||
dc_id,
|
||||
)
|
||||
for ci in range(2):
|
||||
cname = f"{dc_key}-cluster-{ci+1}"
|
||||
cid = stable_id("cluster", cname)
|
||||
clusters.append((cid, dc_id, cname))
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_clusters(id, datacenter_id, name, description, version_major, version_minor)
|
||||
VALUES($1,$2,$3,$4,$5,$6)""",
|
||||
cid,
|
||||
dc_id,
|
||||
cname,
|
||||
f"Cluster {ci+1} in {dc_name}",
|
||||
maj,
|
||||
minor,
|
||||
)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_affinity_groups(id, cluster_id, name, enforcing, positive)
|
||||
VALUES($1,$2,'web-affinity',true,true)""",
|
||||
stable_id("ag", cname),
|
||||
cid,
|
||||
)
|
||||
for hi in range(4):
|
||||
hname = f"{cname}-host-{hi+1:02d}"
|
||||
hid = stable_id("host", hname)
|
||||
hosts.append(hid)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_hosts(id, cluster_id, name, address, status, memory, cpu_cores, type)
|
||||
VALUES($1,$2,$3,$4,'up',$5,32,'rhel')""",
|
||||
hid,
|
||||
cid,
|
||||
hname,
|
||||
f"10.{dc_specs.index((dc_key, dc_name, local, maj, minor))+10}.{ci+1}.{hi+10}",
|
||||
(256 + hi * 32) * 1024**3,
|
||||
)
|
||||
# networks
|
||||
for nname, vlan in (("ovirtmgmt", None), ("vm-net", 100), ("storage-net", 200)):
|
||||
nid = stable_id("net", dc_key, nname)
|
||||
networks.append(nid)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_networks(id, datacenter_id, name, description, vlan_id)
|
||||
VALUES($1,$2,$3,$4,$5)""",
|
||||
nid,
|
||||
dc_id,
|
||||
nname if nname != "ovirtmgmt" else f"{dc_key}-ovirtmgmt" if dc_key != "dc-prod" else "ovirtmgmt",
|
||||
f"{nname} in {dc_name}",
|
||||
vlan,
|
||||
)
|
||||
pid = stable_id("vnic", dc_key, nname)
|
||||
profiles.append(pid)
|
||||
await conn.execute(
|
||||
"INSERT INTO ov_vnic_profiles(id, network_id, name) VALUES($1,$2,$3)",
|
||||
pid,
|
||||
nid,
|
||||
nname,
|
||||
)
|
||||
# storage domains
|
||||
for si, stype in enumerate(storage_types):
|
||||
sname = f"{dc_key}-{stype}-{si+1}"
|
||||
sid = stable_id("sd", sname)
|
||||
storage_domains.append(sid)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_storage_domains(id, name, type, storage_type, status, available, used)
|
||||
VALUES($1,$2,'data',$3,'active',$4,$5)""",
|
||||
sid,
|
||||
sname,
|
||||
stype,
|
||||
(5 + si) * 1024**4,
|
||||
si * 200 * 1024**3,
|
||||
)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_storage_domain_attachments(id, storage_domain_id, datacenter_id, status)
|
||||
VALUES($1,$2,$3,'active')""",
|
||||
stable_id("sda", sname),
|
||||
sid,
|
||||
dc_id,
|
||||
)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_storage_connections(id, type, address, path)
|
||||
VALUES($1,$2,$3,$4)""",
|
||||
stable_id("sc", sname),
|
||||
stype if stype != "localfs" else "localfs",
|
||||
f"storage-{si}.lab.local",
|
||||
f"/export/{sname}",
|
||||
)
|
||||
|
||||
blank_id = stable_id("template", "Blank")
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_templates(id, cluster_id, name, description, status, memory, cpu_sockets, cpu_cores)
|
||||
VALUES($1,$2,'Blank','Blank template','ok',$3,1,1)""",
|
||||
blank_id,
|
||||
clusters[0][0],
|
||||
1024**3,
|
||||
)
|
||||
for tname, mem, cores in (
|
||||
("rhel8-base", 4 * 1024**3, 2),
|
||||
("rhel9-base", 4 * 1024**3, 2),
|
||||
("win2022-base", 8 * 1024**3, 4),
|
||||
("ubuntu2204-base", 2 * 1024**3, 2),
|
||||
):
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_templates(id, cluster_id, name, description, status, memory, cpu_sockets, cpu_cores)
|
||||
VALUES($1,$2,$3,$4,'ok',$5,1,$6)""",
|
||||
stable_id("template", tname),
|
||||
clusters[0][0],
|
||||
tname,
|
||||
f"Template {tname}",
|
||||
mem,
|
||||
cores,
|
||||
)
|
||||
|
||||
# ~1000 VMs spread across clusters
|
||||
statuses = ["up", "up", "up", "down", "down", "suspended", "powering_up"]
|
||||
os_types = ["rhel_8x64", "rhel_9x64", "ubuntu_22_04", "windows_2022", "other"]
|
||||
default_profile = profiles[0]
|
||||
default_sd = storage_domains[0]
|
||||
|
||||
vm_rows = []
|
||||
disk_rows = []
|
||||
da_rows = []
|
||||
nic_rows = []
|
||||
snap_rows = []
|
||||
|
||||
for i in range(DEMO_VM_COUNT):
|
||||
cluster_id, _dc, cname = clusters[i % len(clusters)]
|
||||
host_id = hosts[i % len(hosts)] if i % 3 != 0 else None
|
||||
status = statuses[i % len(statuses)]
|
||||
if status == "down":
|
||||
host_id = None
|
||||
name = f"vm-{i+1:04d}"
|
||||
vm_id = stable_id("vm", name)
|
||||
memory = (1 + (i % 8)) * 1024**3
|
||||
cores = 1 + (i % 8)
|
||||
vm_rows.append(
|
||||
(
|
||||
vm_id,
|
||||
cluster_id,
|
||||
blank_id,
|
||||
name,
|
||||
f"Demo VM {i+1}",
|
||||
status,
|
||||
memory,
|
||||
1,
|
||||
cores,
|
||||
1,
|
||||
os_types[i % len(os_types)],
|
||||
"server" if i % 5 else "desktop",
|
||||
host_id,
|
||||
)
|
||||
)
|
||||
disk_id = stable_id("disk", name)
|
||||
size = (10 + (i % 50)) * 1024**3
|
||||
disk_rows.append(
|
||||
(disk_id, f"{name}_Disk1", size, size, storage_domains[i % len(storage_domains)])
|
||||
)
|
||||
da_rows.append((stable_id("da", name), vm_id, disk_id, True))
|
||||
nic_rows.append(
|
||||
(
|
||||
stable_id("nic", name),
|
||||
vm_id,
|
||||
"nic1",
|
||||
f"00:1a:4a:{(i >> 16) & 0xFF:02x}:{(i >> 8) & 0xFF:02x}:{i & 0xFF:02x}",
|
||||
profiles[i % len(profiles)] if profiles else default_profile,
|
||||
)
|
||||
)
|
||||
if i % 7 == 0:
|
||||
snap_rows.append(
|
||||
(stable_id("snap", name, "1"), vm_id, f"snapshot-{name}", "ok")
|
||||
)
|
||||
|
||||
# Batch insert VMs
|
||||
await conn.executemany(
|
||||
"""INSERT INTO ov_vms(id, cluster_id, template_id, name, description, status,
|
||||
memory, cpu_sockets, cpu_cores, cpu_threads, os_type, type, host_id)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)""",
|
||||
vm_rows,
|
||||
)
|
||||
await conn.executemany(
|
||||
"""INSERT INTO ov_disks(id, name, provisioned_size, actual_size, storage_domain_id)
|
||||
VALUES($1,$2,$3,$4,$5)""",
|
||||
disk_rows,
|
||||
)
|
||||
await conn.executemany(
|
||||
"""INSERT INTO ov_disk_attachments(id, vm_id, disk_id, bootable)
|
||||
VALUES($1,$2,$3,$4)""",
|
||||
da_rows,
|
||||
)
|
||||
await conn.executemany(
|
||||
"""INSERT INTO ov_nics(id, vm_id, name, mac_address, vnic_profile_id)
|
||||
VALUES($1,$2,$3,$4,$5)""",
|
||||
nic_rows,
|
||||
)
|
||||
if snap_rows:
|
||||
await conn.executemany(
|
||||
"""INSERT INTO ov_snapshots(id, vm_id, description, status)
|
||||
VALUES($1,$2,$3,$4)""",
|
||||
snap_rows,
|
||||
)
|
||||
|
||||
# Tags, bookmarks, events, jobs, surface objects
|
||||
for tname in ("production", "web", "database", "batch", "gpu"):
|
||||
tid = stable_id("tag", tname)
|
||||
await conn.execute(
|
||||
"INSERT INTO ov_tags(id, name, description) VALUES($1,$2,$3)",
|
||||
tid,
|
||||
tname,
|
||||
f"Tag {tname}",
|
||||
)
|
||||
for i in range(0, min(50, DEMO_VM_COUNT), 10):
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_tag_assignments(id, tag_id, object_type, object_id)
|
||||
VALUES($1,$2,'vm',$3) ON CONFLICT DO NOTHING""",
|
||||
stable_id("ta", tname, str(i)),
|
||||
tid,
|
||||
stable_id("vm", f"vm-{i+1:04d}"),
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"INSERT INTO ov_bookmarks(id, name, value) VALUES($1,'UpVMs','Vms: status=up')",
|
||||
stable_id("bm", "UpVMs"),
|
||||
)
|
||||
for i in range(50):
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_events(code, severity, description, user_id)
|
||||
VALUES($1,$2,$3,$4)""",
|
||||
1000 + i,
|
||||
"normal" if i % 4 else "warning",
|
||||
f"Demo event {i}",
|
||||
users["admin"],
|
||||
)
|
||||
for i in range(20):
|
||||
jid = stable_id("job", str(i))
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_jobs(id, description, status, owner_id)
|
||||
VALUES($1,$2,'finished',$3)""",
|
||||
jid,
|
||||
f"Demo job {i}",
|
||||
users["admin"],
|
||||
)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_job_steps(id, job_id, description, status, type, number)
|
||||
VALUES($1,$2,$3,'finished','executing',1)""",
|
||||
stable_id("step", str(i)),
|
||||
jid,
|
||||
f"Step for job {i}",
|
||||
)
|
||||
|
||||
for collection, names in (
|
||||
("instancetypes", ["Tiny", "Small", "Medium", "Large", "XLarge"]),
|
||||
("macpools", ["Default", "Secondary"]),
|
||||
("schedulingpolicies", ["evenly_distributed", "power_saving", "vm_evenly_distributed"]),
|
||||
("schedulingpolicyunits", ["EvenlyDistributed", "PowerSaving", "VmEvenlyDistributed"]),
|
||||
("clusterlevels", ["4.3", "4.4", "4.5"]),
|
||||
("icons", ["default", "custom"]),
|
||||
("operatingsystems", ["rhel_8x64", "rhel_9x64", "windows_2022", "ubuntu_22_04"]),
|
||||
("networkfilters", ["vdsm-no-mac-spoofing"]),
|
||||
("vmpools", ["web-pool", "batch-pool"]),
|
||||
("affinitylabels", ["label-a", "label-b"]),
|
||||
("katelloerrata", ["RHSA-2024:0001", "RHBA-2024:0002"]),
|
||||
("externalhostproviders", ["foreman-lab"]),
|
||||
("openstacknetworkproviders", ["ovn-provider"]),
|
||||
("openstackimageproviders", ["glance-lab"]),
|
||||
("openstackvolumeproviders", ["cinder-lab"]),
|
||||
("imagetransfers", ["transfer-1"]),
|
||||
):
|
||||
for name in names:
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_api_objects(id, collection, name, status, data)
|
||||
VALUES($1,$2,$3,'ok',$4::jsonb)""",
|
||||
stable_id("obj", collection, name),
|
||||
collection,
|
||||
name,
|
||||
json.dumps({"name": name, "description": f"demo {collection}"}),
|
||||
)
|
||||
|
||||
from app.ovirt.settings import seed_engine_options
|
||||
|
||||
await seed_engine_options(conn)
|
||||
|
||||
from app.ovirt.seed_nested import seed_nested_for_inventory
|
||||
|
||||
dc_ids = [stable_id("dc", key) for key, *_ in dc_specs]
|
||||
cluster_ids = [c[0] for c in clusters]
|
||||
template_ids = [
|
||||
blank_id,
|
||||
*[
|
||||
stable_id("template", n)
|
||||
for n in ("rhel8-base", "rhel9-base", "win2022-base", "ubuntu2204-base")
|
||||
],
|
||||
]
|
||||
tag_ids = [stable_id("tag", t) for t in ("production", "web", "database", "batch", "gpu")]
|
||||
await seed_nested_for_inventory(
|
||||
conn,
|
||||
admin_user_id=users["admin"],
|
||||
role_user_id=role_user,
|
||||
datacenter_ids=dc_ids,
|
||||
cluster_ids=cluster_ids,
|
||||
host_ids=list(hosts),
|
||||
network_ids=list(networks),
|
||||
storage_domain_ids=list(storage_domains),
|
||||
template_ids=template_ids,
|
||||
vm_ids=[stable_id("vm", f"vm-{i:04d}") for i in range(1, DEMO_VM_COUNT + 1)],
|
||||
disk_ids=[stable_id("disk", f"vm-{i:04d}") for i in range(1, DEMO_VM_COUNT + 1)],
|
||||
tag_ids=tag_ids,
|
||||
user_ids=list(users.values()),
|
||||
group_ids=[
|
||||
stable_id("group", n)
|
||||
for n in ("engine-admins", "developers", "operators", "readers")
|
||||
],
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"INSERT INTO ov_demo_meta(key, value) VALUES('profile', $1)", DEMO_PROFILE
|
||||
)
|
||||
return {
|
||||
"profile": DEMO_PROFILE,
|
||||
"vms": DEMO_VM_COUNT,
|
||||
"hosts": len(hosts),
|
||||
"datacenters": len(dc_specs),
|
||||
"clusters": len(clusters),
|
||||
"storage_domains": len(storage_domains),
|
||||
"networks": len(networks),
|
||||
}
|
||||
|
||||
|
||||
# Re-export for web routes
|
||||
__all__ = ["DEMO_PROFILE", "DEMO_VM_COUNT", "clear_ovirt_state", "seed_ovirt", "seed_ovirt_demo"]
|
||||
@@ -0,0 +1,22 @@
|
||||
"""FastAPI dependencies for oVirt routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from app.db.pool import AsyncpgDatabase
|
||||
from app.ovirt.auth import AuthContext, resolve_request_auth
|
||||
from app.ovirt.errors import OVirtError
|
||||
|
||||
|
||||
def get_db(request: Request) -> AsyncpgDatabase:
|
||||
db = getattr(request.app.state, "database", None)
|
||||
if db is None:
|
||||
raise OVirtError("ServiceUnavailable", "Database not ready", status_code=503)
|
||||
return db # type: ignore[return-value]
|
||||
|
||||
|
||||
async def require_auth(request: Request) -> AuthContext:
|
||||
db = get_db(request)
|
||||
async with db.pool.acquire() as conn:
|
||||
return await resolve_request_auth(conn, dict(request.headers))
|
||||
@@ -0,0 +1,68 @@
|
||||
"""oVirt Engine fault responses (XML/JSON)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
|
||||
|
||||
class OVirtError(Exception):
|
||||
def __init__(
|
||||
self,
|
||||
reason: str,
|
||||
detail: str,
|
||||
*,
|
||||
status_code: int = 400,
|
||||
code: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(detail)
|
||||
self.reason = reason
|
||||
self.detail = detail
|
||||
self.status_code = status_code
|
||||
self.code = code or reason
|
||||
|
||||
|
||||
def _wants_xml(request: Request) -> bool:
|
||||
accept = (request.headers.get("accept") or "").lower()
|
||||
content = (request.headers.get("content-type") or "").lower()
|
||||
if "application/xml" in accept or "text/xml" in accept:
|
||||
return True
|
||||
if "json" in accept:
|
||||
return False
|
||||
if "xml" in content:
|
||||
return True
|
||||
# Engine default is XML when Accept is omitted / */*
|
||||
return "json" not in accept
|
||||
|
||||
|
||||
def fault_body(error: OVirtError, *, as_xml: bool) -> str | dict[str, Any]:
|
||||
if as_xml:
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
f"<fault><reason>{_esc(error.reason)}</reason>"
|
||||
f"<detail>{_esc(error.detail)}</detail></fault>"
|
||||
)
|
||||
return {"fault": {"reason": error.reason, "detail": error.detail}}
|
||||
|
||||
|
||||
def _esc(value: str) -> str:
|
||||
return (
|
||||
value.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """)
|
||||
)
|
||||
|
||||
|
||||
async def ovirt_error_handler(request: Request, exc: OVirtError) -> Response:
|
||||
as_xml = _wants_xml(request)
|
||||
body = fault_body(exc, as_xml=as_xml)
|
||||
if as_xml:
|
||||
return Response(
|
||||
content=str(body),
|
||||
status_code=exc.status_code,
|
||||
media_type="application/xml",
|
||||
)
|
||||
return JSONResponse(content=body, status_code=exc.status_code)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Deterministic UUIDs for seed data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID, uuid5
|
||||
|
||||
NAMESPACE = UUID("a1b2c3d4-e5f6-7890-abcd-ef1234567890")
|
||||
|
||||
|
||||
def stable_id(*parts: str) -> UUID:
|
||||
return uuid5(NAMESPACE, ":".join(parts))
|
||||
|
||||
|
||||
def stable_str(*parts: str) -> str:
|
||||
return str(stable_id(*parts))
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Async job/task helpers for Engine actions — rows are always loaded from Postgres."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from asyncpg import Connection
|
||||
from fastapi import Request, Response
|
||||
|
||||
from app.ovirt.repr import action_entity, job_entity
|
||||
from app.ovirt.serialize import respond
|
||||
from app.ovirt.settings import (
|
||||
OPT_DEFAULT_ACTION_STATUS,
|
||||
OPT_DEFAULT_JOB_STATUS_COMPLETE,
|
||||
OPT_DEFAULT_JOB_STATUS_STARTED,
|
||||
OPT_DEFAULT_JOB_STEP_TYPE,
|
||||
option_value,
|
||||
)
|
||||
|
||||
|
||||
async def create_job(
|
||||
conn: Connection,
|
||||
*,
|
||||
description: str,
|
||||
owner_id: Any = None,
|
||||
auto_complete: bool = True,
|
||||
action_status: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
job_id = uuid4()
|
||||
now = datetime.now(UTC)
|
||||
finished = await option_value(conn, OPT_DEFAULT_JOB_STATUS_COMPLETE)
|
||||
started = await option_value(conn, OPT_DEFAULT_JOB_STATUS_STARTED)
|
||||
status = finished if auto_complete else started
|
||||
ended = now if auto_complete else None
|
||||
if action_status is None:
|
||||
action_status = await option_value(conn, OPT_DEFAULT_ACTION_STATUS)
|
||||
step_type = await option_value(conn, OPT_DEFAULT_JOB_STEP_TYPE)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_jobs(id, description, status, started, ended, owner_id, data)
|
||||
VALUES($1, $2, $3, $4, $5, $6, $7::jsonb)""",
|
||||
job_id,
|
||||
description,
|
||||
status,
|
||||
now,
|
||||
ended,
|
||||
owner_id,
|
||||
json.dumps({"action_status": action_status}),
|
||||
)
|
||||
step_id = uuid4()
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_job_steps(id, job_id, description, status, type, number, started, ended)
|
||||
VALUES($1, $2, $3, $4, $5, 1, $6, $7)""",
|
||||
step_id,
|
||||
job_id,
|
||||
description,
|
||||
status,
|
||||
step_type,
|
||||
now,
|
||||
ended,
|
||||
)
|
||||
row = await conn.fetchrow("SELECT * FROM ov_jobs WHERE id=$1", job_id)
|
||||
return job_entity(row)
|
||||
|
||||
|
||||
async def complete_job(conn: Connection, job_id: str, *, status: str | None = None) -> None:
|
||||
now = datetime.now(UTC)
|
||||
if status is None:
|
||||
status = await option_value(conn, OPT_DEFAULT_JOB_STATUS_COMPLETE)
|
||||
await conn.execute(
|
||||
"UPDATE ov_jobs SET status=$2, ended=$3 WHERE id=$1::uuid",
|
||||
job_id,
|
||||
status,
|
||||
now,
|
||||
)
|
||||
await conn.execute(
|
||||
"UPDATE ov_job_steps SET status=$2, ended=$3 WHERE job_id=$1::uuid",
|
||||
job_id,
|
||||
status,
|
||||
now,
|
||||
)
|
||||
|
||||
|
||||
async def respond_action(
|
||||
request: Request,
|
||||
conn: Connection,
|
||||
*,
|
||||
description: str,
|
||||
owner_id: Any = None,
|
||||
auto_complete: bool = True,
|
||||
) -> Response:
|
||||
"""Persist a job and return an action entity built solely from the DB row."""
|
||||
|
||||
job = await create_job(
|
||||
conn, description=description, owner_id=owner_id, auto_complete=auto_complete
|
||||
)
|
||||
row = await conn.fetchrow("SELECT * FROM ov_jobs WHERE id=$1::uuid", job["id"])
|
||||
return respond(request, element="action", data=action_entity(row))
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Mount oVirt Engine API + SSO onto the FastAPI application."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.ovirt.contract_loader import ensure_loaded
|
||||
from app.ovirt.errors import OVirtError, ovirt_error_handler
|
||||
from app.ovirt.registry import register_ovirt_contract_routes
|
||||
from app.ovirt.routes import engine, sso
|
||||
|
||||
|
||||
def mount_ovirt_routes(app: FastAPI, *, series: str = "4.5") -> None:
|
||||
"""Register Engine REST API, SSO, and load the active series pack.
|
||||
|
||||
Contract operations are registered as individual OpenAPI routes.
|
||||
Catch-all handlers remain as a hidden fallback.
|
||||
"""
|
||||
|
||||
app.add_exception_handler(OVirtError, ovirt_error_handler)
|
||||
app.include_router(sso.router)
|
||||
|
||||
rt = ensure_loaded(series)
|
||||
try:
|
||||
summary = rt.reload(series)
|
||||
except FileNotFoundError:
|
||||
summary = {"operation_count": 0, "series": series}
|
||||
registered = 0
|
||||
if rt.pack is not None:
|
||||
registered = register_ovirt_contract_routes(app, rt.pack)
|
||||
|
||||
# Fallback catch-all after specific contract routes (first match wins).
|
||||
app.include_router(engine.router)
|
||||
|
||||
app.state.ovirt_series = series
|
||||
app.state.ovirt_schema_ops = registered or summary.get("operation_count", 0)
|
||||
app.state.runtime_version = f"ovirt-{series}"
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Contract operation specifications for Engine API packs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal
|
||||
|
||||
HttpMethod = Literal["GET", "POST", "PUT", "DELETE"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OperationSpec:
|
||||
operation_id: str
|
||||
method: HttpMethod
|
||||
path: str
|
||||
resource_type: str
|
||||
collection_key: str
|
||||
element: str
|
||||
kind: str = "collection"
|
||||
search: bool = False
|
||||
introduced_in: str = "3.0"
|
||||
requires_auth: bool = True
|
||||
status_code: int = 200
|
||||
create_status: int = 201
|
||||
notes: str = ""
|
||||
response_fixture: Any | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SeriesPack:
|
||||
series: str
|
||||
api_version: str
|
||||
major: int
|
||||
operations: list[OperationSpec] = field(default_factory=list)
|
||||
product: dict[str, str] = field(default_factory=dict)
|
||||
entry_point_links: list[dict[str, str]] = field(default_factory=list)
|
||||
checksum: str = ""
|
||||
|
||||
def operation_count(self) -> int:
|
||||
return len(self.operations)
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Register each Engine contract operation as its own FastAPI route."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Awaitable, Callable
|
||||
from enum import Enum
|
||||
from typing import cast
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import Response
|
||||
|
||||
from app.api.openapi import contract_openapi_tags
|
||||
from app.ovirt.opspec import OperationSpec, SeriesPack
|
||||
|
||||
Endpoint = Callable[[Request], Awaitable[Response]]
|
||||
_NON_ALNUM = re.compile(r"[^a-zA-Z0-9_]+")
|
||||
|
||||
|
||||
def clear_ovirt_contract_routes(app: FastAPI) -> None:
|
||||
"""Drop contract + Engine fallback routes before a remount."""
|
||||
|
||||
app.router.routes = [
|
||||
route for route in app.router.routes if not _is_swappable_ovirt_route(route)
|
||||
]
|
||||
app.openapi_schema = None
|
||||
|
||||
|
||||
def register_ovirt_contract_routes(app: FastAPI, pack: SeriesPack) -> int:
|
||||
"""Register one FastAPI route per contract operation.
|
||||
|
||||
Paths are taken from the pack as-is (including ``/v3`` / ``/v4`` variants).
|
||||
Returns the number of routes added.
|
||||
"""
|
||||
|
||||
seen: set[tuple[str, str]] = set()
|
||||
registered = 0
|
||||
for op in pack.operations:
|
||||
method = op.method.upper()
|
||||
path = _normalize_route_path(op.path)
|
||||
key = (path, method)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
app.add_api_route(
|
||||
path,
|
||||
_endpoint_for(op),
|
||||
methods=[method],
|
||||
name=f"contract:{method}:{path}",
|
||||
tags=cast(list[str | Enum], contract_openapi_tags(path)),
|
||||
summary=op.notes or op.operation_id,
|
||||
operation_id=_unique_operation_id(op, method, path),
|
||||
openapi_extra={
|
||||
"x-ovirt-operation-id": op.operation_id,
|
||||
"x-ovirt-series": pack.series,
|
||||
"x-ovirt-kind": op.kind,
|
||||
},
|
||||
)
|
||||
registered += 1
|
||||
app.openapi_schema = None
|
||||
return registered
|
||||
|
||||
|
||||
def _is_swappable_ovirt_route(route: object) -> bool:
|
||||
name = getattr(route, "name", None)
|
||||
return isinstance(name, str) and (
|
||||
name.startswith("contract:") or name.startswith("ovirt-fallback:")
|
||||
)
|
||||
|
||||
|
||||
def _unique_operation_id(op: OperationSpec, method: str, path: str) -> str:
|
||||
"""Build a unique OpenAPI operationId (packs repeat ids across /v3|/v4)."""
|
||||
|
||||
base = op.operation_id.replace(".", "_")
|
||||
suffix = _NON_ALNUM.sub("_", f"{method}_{path}").strip("_")
|
||||
return f"{base}__{suffix}"
|
||||
|
||||
|
||||
def _normalize_route_path(path: str) -> str:
|
||||
"""Ensure FastAPI path template form (leading slash, no trailing slash except root)."""
|
||||
|
||||
cleaned = path.strip() or "/"
|
||||
if not cleaned.startswith("/"):
|
||||
cleaned = f"/{cleaned}"
|
||||
if cleaned != "/" and cleaned.endswith("/"):
|
||||
cleaned = cleaned.rstrip("/")
|
||||
return cleaned
|
||||
|
||||
|
||||
def _endpoint_for(op: OperationSpec) -> Endpoint:
|
||||
async def dispatch(request: Request) -> Response:
|
||||
from app.ovirt.routes.engine import handle_engine_request
|
||||
|
||||
return await handle_engine_request(request)
|
||||
|
||||
dispatch.__name__ = f"contract_{op.operation_id.replace('.', '_')}"
|
||||
dispatch.__doc__ = op.notes or op.operation_id
|
||||
return dispatch
|
||||
@@ -0,0 +1,353 @@
|
||||
"""Map database rows to oVirt Engine API entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _data(row: Any) -> dict[str, Any]:
|
||||
raw = row["data"] if "data" in row.keys() else {}
|
||||
if isinstance(raw, str):
|
||||
raw = json.loads(raw)
|
||||
return dict(raw or {})
|
||||
|
||||
|
||||
def href(collection: str, object_id: Any) -> str:
|
||||
return f"/ovirt-engine/api/{collection}/{object_id}"
|
||||
|
||||
|
||||
def link(rel: str, path: str) -> dict[str, str]:
|
||||
return {"@rel": rel, "href": path} if False else {"rel": rel, "href": path}
|
||||
|
||||
|
||||
def vm_entity(row: Any) -> dict[str, Any]:
|
||||
vid = str(row["id"])
|
||||
entity = {
|
||||
"id": vid,
|
||||
"href": href("vms", vid),
|
||||
"name": row["name"],
|
||||
"description": row["description"] or "",
|
||||
"status": row["status"],
|
||||
"type": row["type"],
|
||||
"memory": int(row["memory"]),
|
||||
"cpu": {
|
||||
"topology": {
|
||||
"sockets": int(row["cpu_sockets"]),
|
||||
"cores": int(row["cpu_cores"]),
|
||||
"threads": int(row["cpu_threads"]),
|
||||
}
|
||||
},
|
||||
"os": {"type": row["os_type"]},
|
||||
"cluster": {"id": str(row["cluster_id"]), "href": href("clusters", row["cluster_id"])},
|
||||
"link": [
|
||||
{"rel": "diskattachments", "href": f"/ovirt-engine/api/vms/{vid}/diskattachments"},
|
||||
{"rel": "nics", "href": f"/ovirt-engine/api/vms/{vid}/nics"},
|
||||
{"rel": "snapshots", "href": f"/ovirt-engine/api/vms/{vid}/snapshots"},
|
||||
{"rel": "tags", "href": f"/ovirt-engine/api/vms/{vid}/tags"},
|
||||
{"rel": "permissions", "href": f"/ovirt-engine/api/vms/{vid}/permissions"},
|
||||
{"rel": "cdroms", "href": f"/ovirt-engine/api/vms/{vid}/cdroms"},
|
||||
{"rel": "graphicsconsoles", "href": f"/ovirt-engine/api/vms/{vid}/graphicsconsoles"},
|
||||
],
|
||||
}
|
||||
if row["template_id"]:
|
||||
entity["template"] = {
|
||||
"id": str(row["template_id"]),
|
||||
"href": href("templates", row["template_id"]),
|
||||
}
|
||||
if row["host_id"]:
|
||||
entity["host"] = {"id": str(row["host_id"]), "href": href("hosts", row["host_id"])}
|
||||
entity.update({k: v for k, v in _data(row).items() if k not in entity})
|
||||
return entity
|
||||
|
||||
|
||||
def disk_entity(row: Any) -> dict[str, Any]:
|
||||
did = str(row["id"])
|
||||
entity = {
|
||||
"id": did,
|
||||
"href": href("disks", did),
|
||||
"name": row["name"],
|
||||
"description": row["description"] or "",
|
||||
"status": row["status"],
|
||||
"provisioned_size": int(row["provisioned_size"]),
|
||||
"actual_size": int(row["actual_size"]),
|
||||
"format": row["format"],
|
||||
"sparse": bool(row["sparse"]),
|
||||
"shareable": bool(row["shareable"]),
|
||||
"wipe_after_delete": bool(row["wipe_after_delete"]),
|
||||
"link": [
|
||||
{"rel": "permissions", "href": f"/ovirt-engine/api/disks/{did}/permissions"},
|
||||
{"rel": "statistics", "href": f"/ovirt-engine/api/disks/{did}/statistics"},
|
||||
],
|
||||
}
|
||||
if row["storage_domain_id"]:
|
||||
entity["storage_domains"] = {
|
||||
"storage_domain": [
|
||||
{
|
||||
"id": str(row["storage_domain_id"]),
|
||||
"href": href("storagedomains", row["storage_domain_id"]),
|
||||
}
|
||||
]
|
||||
}
|
||||
entity.update({k: v for k, v in _data(row).items() if k not in entity})
|
||||
return entity
|
||||
|
||||
|
||||
def host_entity(row: Any) -> dict[str, Any]:
|
||||
hid = str(row["id"])
|
||||
return {
|
||||
"id": hid,
|
||||
"href": href("hosts", hid),
|
||||
"name": row["name"],
|
||||
"address": row["address"],
|
||||
"status": row["status"],
|
||||
"type": row["type"],
|
||||
"memory": int(row["memory"]),
|
||||
"cpu": {"topology": {"cores": int(row["cpu_cores"])}},
|
||||
"cluster": {"id": str(row["cluster_id"]), "href": href("clusters", row["cluster_id"])},
|
||||
"link": [
|
||||
{"rel": "nics", "href": f"/ovirt-engine/api/hosts/{hid}/nics"},
|
||||
{"rel": "tags", "href": f"/ovirt-engine/api/hosts/{hid}/tags"},
|
||||
{"rel": "permissions", "href": f"/ovirt-engine/api/hosts/{hid}/permissions"},
|
||||
{"rel": "statistics", "href": f"/ovirt-engine/api/hosts/{hid}/statistics"},
|
||||
],
|
||||
**{k: v for k, v in _data(row).items()},
|
||||
}
|
||||
|
||||
|
||||
def datacenter_entity(row: Any) -> dict[str, Any]:
|
||||
did = str(row["id"])
|
||||
return {
|
||||
"id": did,
|
||||
"href": href("datacenters", did),
|
||||
"name": row["name"],
|
||||
"description": row["description"] or "",
|
||||
"local": bool(row["local"]),
|
||||
"status": row["status"],
|
||||
"version": {"major": int(row["version_major"]), "minor": int(row["version_minor"])},
|
||||
"link": [
|
||||
{"rel": "clusters", "href": f"/ovirt-engine/api/datacenters/{did}/clusters"},
|
||||
{"rel": "storagedomains", "href": f"/ovirt-engine/api/datacenters/{did}/storagedomains"},
|
||||
{"rel": "networks", "href": f"/ovirt-engine/api/datacenters/{did}/networks"},
|
||||
{"rel": "quotas", "href": f"/ovirt-engine/api/datacenters/{did}/quotas"},
|
||||
{"rel": "permissions", "href": f"/ovirt-engine/api/datacenters/{did}/permissions"},
|
||||
],
|
||||
**{k: v for k, v in _data(row).items()},
|
||||
}
|
||||
|
||||
|
||||
def cluster_entity(row: Any) -> dict[str, Any]:
|
||||
cid = str(row["id"])
|
||||
return {
|
||||
"id": cid,
|
||||
"href": href("clusters", cid),
|
||||
"name": row["name"],
|
||||
"description": row["description"] or "",
|
||||
"cpu": {"type": row["cpu_type"]},
|
||||
"version": {"major": int(row["version_major"]), "minor": int(row["version_minor"])},
|
||||
"data_center": {
|
||||
"id": str(row["datacenter_id"]),
|
||||
"href": href("datacenters", row["datacenter_id"]),
|
||||
},
|
||||
"link": [
|
||||
{"rel": "networks", "href": f"/ovirt-engine/api/clusters/{cid}/networks"},
|
||||
{"rel": "affinitygroups", "href": f"/ovirt-engine/api/clusters/{cid}/affinitygroups"},
|
||||
{"rel": "permissions", "href": f"/ovirt-engine/api/clusters/{cid}/permissions"},
|
||||
],
|
||||
**{k: v for k, v in _data(row).items()},
|
||||
}
|
||||
|
||||
|
||||
def network_entity(row: Any) -> dict[str, Any]:
|
||||
nid = str(row["id"])
|
||||
entity: dict[str, Any] = {
|
||||
"id": nid,
|
||||
"href": href("networks", nid),
|
||||
"name": row["name"],
|
||||
"description": row["description"] or "",
|
||||
"stp": bool(row["stp"]),
|
||||
"data_center": {
|
||||
"id": str(row["datacenter_id"]),
|
||||
"href": href("datacenters", row["datacenter_id"]),
|
||||
},
|
||||
"link": [
|
||||
{"rel": "vnicprofiles", "href": f"/ovirt-engine/api/networks/{nid}/vnicprofiles"},
|
||||
{"rel": "permissions", "href": f"/ovirt-engine/api/networks/{nid}/permissions"},
|
||||
],
|
||||
}
|
||||
if row["vlan_id"] is not None:
|
||||
entity["vlan"] = {"id": int(row["vlan_id"])}
|
||||
entity.update(_data(row))
|
||||
return entity
|
||||
|
||||
|
||||
def storage_domain_entity(row: Any) -> dict[str, Any]:
|
||||
sid = str(row["id"])
|
||||
return {
|
||||
"id": sid,
|
||||
"href": href("storagedomains", sid),
|
||||
"name": row["name"],
|
||||
"type": row["type"],
|
||||
"storage": {"type": row["storage_type"]},
|
||||
"status": row["status"],
|
||||
"available": int(row["available"]),
|
||||
"used": int(row["used"]),
|
||||
"committed": int(row["committed"]),
|
||||
"link": [
|
||||
{"rel": "disks", "href": f"/ovirt-engine/api/storagedomains/{sid}/disks"},
|
||||
{"rel": "files", "href": f"/ovirt-engine/api/storagedomains/{sid}/files"},
|
||||
{"rel": "permissions", "href": f"/ovirt-engine/api/storagedomains/{sid}/permissions"},
|
||||
],
|
||||
**{k: v for k, v in _data(row).items()},
|
||||
}
|
||||
|
||||
|
||||
def template_entity(row: Any) -> dict[str, Any]:
|
||||
tid = str(row["id"])
|
||||
entity: dict[str, Any] = {
|
||||
"id": tid,
|
||||
"href": href("templates", tid),
|
||||
"name": row["name"],
|
||||
"description": row["description"] or "",
|
||||
"status": row["status"],
|
||||
"memory": int(row["memory"]),
|
||||
"cpu": {
|
||||
"topology": {"sockets": int(row["cpu_sockets"]), "cores": int(row["cpu_cores"])}
|
||||
},
|
||||
"link": [
|
||||
{"rel": "diskattachments", "href": f"/ovirt-engine/api/templates/{tid}/diskattachments"},
|
||||
{"rel": "nics", "href": f"/ovirt-engine/api/templates/{tid}/nics"},
|
||||
],
|
||||
}
|
||||
if row["cluster_id"]:
|
||||
entity["cluster"] = {
|
||||
"id": str(row["cluster_id"]),
|
||||
"href": href("clusters", row["cluster_id"]),
|
||||
}
|
||||
entity.update(_data(row))
|
||||
return entity
|
||||
|
||||
|
||||
def user_entity(row: Any) -> dict[str, Any]:
|
||||
uid = str(row["id"])
|
||||
return {
|
||||
"id": uid,
|
||||
"href": href("users", uid),
|
||||
"name": row["name"],
|
||||
"user_name": f"{row['name']}@{row['domain_name']}",
|
||||
"domain": {"id": str(row["domain_id"]), "name": row["domain_name"]},
|
||||
"link": [
|
||||
{"rel": "roles", "href": f"/ovirt-engine/api/users/{uid}/roles"},
|
||||
{"rel": "permissions", "href": f"/ovirt-engine/api/users/{uid}/permissions"},
|
||||
{"rel": "tags", "href": f"/ovirt-engine/api/users/{uid}/tags"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def generic_entity(collection: str, element: str, row: Any) -> dict[str, Any]:
|
||||
if row is None:
|
||||
from app.ovirt.errors import OVirtError
|
||||
|
||||
raise OVirtError("NotFound", f"{element} not found", status_code=404)
|
||||
oid = str(row["id"])
|
||||
data = _data(row)
|
||||
entity = {
|
||||
"id": oid,
|
||||
"href": href(collection, oid),
|
||||
"name": row["name"] or data.get("name") or element,
|
||||
"status": row["status"],
|
||||
}
|
||||
entity.update(data)
|
||||
return entity
|
||||
|
||||
|
||||
def job_entity(row: Any) -> dict[str, Any]:
|
||||
jid = str(row["id"])
|
||||
entity: dict[str, Any] = {
|
||||
"id": jid,
|
||||
"href": href("jobs", jid),
|
||||
"description": row["description"] or "",
|
||||
"status": row["status"],
|
||||
}
|
||||
if row["started"] is not None:
|
||||
entity["started"] = row["started"].strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
||||
if row["ended"] is not None:
|
||||
entity["ended"] = row["ended"].strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
||||
entity.update({k: v for k, v in _data(row).items() if k not in entity and k != "action_status"})
|
||||
return entity
|
||||
|
||||
|
||||
def action_entity(job_row: Any) -> dict[str, Any]:
|
||||
"""Build an action response from a persisted job row (status from job.data)."""
|
||||
|
||||
data = _data(job_row)
|
||||
status = data.get("action_status")
|
||||
if status is None:
|
||||
status = job_row["status"]
|
||||
return {"status": str(status), "job": job_entity(job_row)}
|
||||
|
||||
|
||||
def disk_attachment_entity(row: Any, *, vm_id: str | None = None) -> dict[str, Any]:
|
||||
vid = vm_id or str(row["vm_id"])
|
||||
aid = str(row["id"])
|
||||
entity: dict[str, Any] = {
|
||||
"id": aid,
|
||||
"href": f"/ovirt-engine/api/vms/{vid}/diskattachments/{aid}",
|
||||
"active": bool(row["active"]),
|
||||
"bootable": bool(row["bootable"]),
|
||||
"interface": row["interface"],
|
||||
"disk": {"id": str(row["disk_id"]), "href": href("disks", row["disk_id"])},
|
||||
"vm": {"id": vid, "href": href("vms", vid)},
|
||||
}
|
||||
if "disk_name" in row.keys() and row["disk_name"] is not None:
|
||||
entity["disk"]["name"] = row["disk_name"]
|
||||
entity.update({k: v for k, v in _data(row).items() if k not in entity})
|
||||
return entity
|
||||
|
||||
|
||||
def nic_entity(row: Any, *, vm_id: str | None = None) -> dict[str, Any]:
|
||||
vid = vm_id or str(row["vm_id"])
|
||||
nid = str(row["id"])
|
||||
entity: dict[str, Any] = {
|
||||
"id": nid,
|
||||
"href": f"/ovirt-engine/api/vms/{vid}/nics/{nid}",
|
||||
"name": row["name"],
|
||||
"interface": row["interface"],
|
||||
"linked": bool(row["linked"]),
|
||||
"plugged": bool(row["plugged"]),
|
||||
"mac": {"address": row["mac_address"] or ""},
|
||||
}
|
||||
if row["vnic_profile_id"]:
|
||||
entity["vnic_profile"] = {
|
||||
"id": str(row["vnic_profile_id"]),
|
||||
"href": href("vnicprofiles", row["vnic_profile_id"]),
|
||||
}
|
||||
entity.update({k: v for k, v in _data(row).items() if k not in entity})
|
||||
return entity
|
||||
|
||||
|
||||
def snapshot_entity(row: Any, *, vm_id: str | None = None) -> dict[str, Any]:
|
||||
vid = vm_id or str(row["vm_id"])
|
||||
sid = str(row["id"])
|
||||
entity: dict[str, Any] = {
|
||||
"id": sid,
|
||||
"href": f"/ovirt-engine/api/vms/{vid}/snapshots/{sid}",
|
||||
"description": row["description"] or "",
|
||||
"status": row["status"],
|
||||
"snapshot_type": row["snapshot_type"],
|
||||
"persist_memorystate": bool(row["persist_memorystate"]),
|
||||
}
|
||||
if row["created_at"] is not None:
|
||||
entity["date"] = row["created_at"].strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
||||
entity.update({k: v for k, v in _data(row).items() if k not in entity})
|
||||
return entity
|
||||
|
||||
|
||||
def tag_entity(row: Any) -> dict[str, Any]:
|
||||
tid = str(row["id"])
|
||||
return {
|
||||
"id": tid,
|
||||
"href": href("tags", tid),
|
||||
"name": row["name"],
|
||||
"description": row["description"] or "",
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""Specialized oVirt Engine route modules."""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
"""oVirt Engine SSO OAuth2 endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.ovirt.auth import issue_oauth_token
|
||||
from app.ovirt.deps import get_db
|
||||
from app.ovirt.errors import OVirtError
|
||||
from app.ovirt.settings import OPT_DEFAULT_API_SCOPE, option_value
|
||||
|
||||
router = APIRouter(tags=["sso"])
|
||||
|
||||
|
||||
@router.post("/ovirt-engine/sso/oauth/token")
|
||||
async def oauth_token(
|
||||
request: Request,
|
||||
grant_type: str = Form(default=""),
|
||||
username: str = Form(default=""),
|
||||
password: str = Form(default=""),
|
||||
scope: str = Form(default=""),
|
||||
) -> JSONResponse:
|
||||
if grant_type and grant_type != "password":
|
||||
raise OVirtError("BadRequest", f"Unsupported grant_type: {grant_type}", status_code=400)
|
||||
if not username:
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
username = str(body.get("username") or "")
|
||||
password = str(body.get("password") or "")
|
||||
scope = str(body.get("scope") or scope or "")
|
||||
grant_type = str(body.get("grant_type") or grant_type or "password")
|
||||
if not username or not password:
|
||||
raise OVirtError("Unauthorized", "username and password required", status_code=401)
|
||||
db = get_db(request)
|
||||
async with db.pool.acquire() as conn:
|
||||
if not scope:
|
||||
scope = await option_value(conn, OPT_DEFAULT_API_SCOPE)
|
||||
token = await issue_oauth_token(
|
||||
conn, username=username, password=password, scope=scope
|
||||
)
|
||||
return JSONResponse(token)
|
||||
|
||||
|
||||
@router.get("/ovirt-engine/sso/oauth/token-info")
|
||||
async def token_info(request: Request) -> JSONResponse:
|
||||
from app.ovirt.auth import resolve_request_auth
|
||||
|
||||
db = get_db(request)
|
||||
async with db.pool.acquire() as conn:
|
||||
ctx = await resolve_request_auth(conn, dict(request.headers))
|
||||
row = await conn.fetchrow(
|
||||
"SELECT scope, revoked, expires_at FROM ov_tokens WHERE id=$1", ctx.token_id
|
||||
)
|
||||
active = bool(row) and not row["revoked"]
|
||||
return JSONResponse(
|
||||
{
|
||||
"active": active,
|
||||
"user_id": str(ctx.user_id),
|
||||
"user_name": f"{ctx.user_name}@{ctx.domain}",
|
||||
"exp": int(ctx.expires_at.timestamp()),
|
||||
"scope": (row["scope"] if row else ctx.scope),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/ovirt-engine/sso/oauth/revoke")
|
||||
async def revoke_token(request: Request) -> JSONResponse:
|
||||
from app.ovirt.auth import extract_auth
|
||||
|
||||
db = get_db(request)
|
||||
kind = extract_auth(dict(request.headers))
|
||||
token = None
|
||||
if kind and kind[0] in {"bearer", "session"}:
|
||||
token = kind[1]
|
||||
else:
|
||||
form = await request.form()
|
||||
token = str(form.get("token") or "")
|
||||
result = "missing"
|
||||
if token:
|
||||
async with db.pool.acquire() as conn:
|
||||
updated = await conn.fetchval(
|
||||
"""UPDATE ov_tokens SET revoked=true WHERE id=$1
|
||||
RETURNING id""",
|
||||
token,
|
||||
)
|
||||
result = "ok" if updated else "not_found"
|
||||
return JSONResponse({"result": result})
|
||||
@@ -0,0 +1,330 @@
|
||||
"""Generic surface-complete CRUD backed by ov_api_objects for undeclared collections."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from asyncpg import Connection
|
||||
from fastapi import Request, Response
|
||||
|
||||
from app.ovirt.errors import OVirtError
|
||||
from app.ovirt.repr import generic_entity
|
||||
from app.ovirt.serialize import respond, unwrap_entity
|
||||
|
||||
# Map collection path segment → (element singular, default status)
|
||||
_COLLECTIONS: dict[str, tuple[str, str]] = {
|
||||
"affinitylabels": ("affinity_label", "ok"),
|
||||
"bookmarks": ("bookmark", "ok"),
|
||||
"clusterlevels": ("cluster_level", "ok"),
|
||||
"domains": ("domain", "ok"),
|
||||
"externalhostproviders": ("external_host_provider", "ok"),
|
||||
"groups": ("group", "ok"),
|
||||
"icons": ("icon", "ok"),
|
||||
"instancetypes": ("instance_type", "ok"),
|
||||
"katelloerrata": ("katello_erratum", "ok"),
|
||||
"macpools": ("mac_pool", "ok"),
|
||||
"networkfilters": ("network_filter", "ok"),
|
||||
"openstackimageproviders": ("openstack_image_provider", "ok"),
|
||||
"openstacknetworkproviders": ("openstack_network_provider", "ok"),
|
||||
"openstackvolumeproviders": ("openstack_volume_provider", "ok"),
|
||||
"operatingsystems": ("operating_system", "ok"),
|
||||
"permissions": ("permission", "ok"),
|
||||
"roles": ("role", "ok"),
|
||||
"schedulingpolicies": ("scheduling_policy", "ok"),
|
||||
"schedulingpolicyunits": ("scheduling_policy_unit", "ok"),
|
||||
"tags": ("tag", "ok"),
|
||||
"vmpools": ("vm_pool", "ok"),
|
||||
"imagetransfers": ("image_transfer", "ok"),
|
||||
"options": ("engine_option", "ok"),
|
||||
"networklabels": ("network_label", "ok"),
|
||||
"cpuprofiles": ("cpu_profile", "ok"),
|
||||
"diskprofiles": ("disk_profile", "ok"),
|
||||
"qoss": ("qos", "ok"),
|
||||
"iscsibonds": ("iscsi_bond", "ok"),
|
||||
"glustervolumes": ("gluster_volume", "ok"),
|
||||
"files": ("file", "ok"),
|
||||
"images": ("image", "ok"),
|
||||
"permits": ("permit", "ok"),
|
||||
"filters": ("filter", "ok"),
|
||||
"weights": ("weight", "ok"),
|
||||
"balances": ("balance", "ok"),
|
||||
"enabledfeatures": ("cluster_enabled_feature", "ok"),
|
||||
"cdroms": ("cdrom", "ok"),
|
||||
"graphicsconsoles": ("graphics_console", "ok"),
|
||||
"reporteddevices": ("reported_device", "ok"),
|
||||
"sessions": ("session", "ok"),
|
||||
"applications": ("application", "ok"),
|
||||
"watchdogs": ("watchdog", "ok"),
|
||||
"hostdevices": ("host_device", "ok"),
|
||||
"numanodes": ("numa_node", "ok"),
|
||||
"mediateddevices": ("vm_mediated_device", "ok"),
|
||||
"statistics": ("statistic", "ok"),
|
||||
"hooks": ("hook", "ok"),
|
||||
"devices": ("host_device", "ok"),
|
||||
"sshpublickeys": ("ssh_public_key", "ok"),
|
||||
"networkfilterparameters": ("network_filter_parameter", "ok"),
|
||||
}
|
||||
|
||||
|
||||
def _meta(collection: str) -> tuple[str, str]:
|
||||
return _COLLECTIONS.get(collection, (collection.rstrip("s") or "object", "ok"))
|
||||
|
||||
|
||||
async def handle_generic(
|
||||
request: Request,
|
||||
conn: Connection,
|
||||
method: str,
|
||||
parts: list[str],
|
||||
payload: dict[str, Any],
|
||||
) -> Response:
|
||||
from app.ovirt.settings import OPT_DEFAULT_API_OBJECT_STATUS, option_value
|
||||
|
||||
collection = parts[0]
|
||||
element, _catalog_status = _meta(collection)
|
||||
default_status = await option_value(conn, OPT_DEFAULT_API_OBJECT_STATUS)
|
||||
if len(parts) == 1:
|
||||
if method == "GET":
|
||||
rows = await conn.fetch(
|
||||
"SELECT * FROM ov_api_objects WHERE collection=$1 ORDER BY name", collection
|
||||
)
|
||||
items = [generic_entity(collection, element, r) for r in rows]
|
||||
return respond(request, element=element, collection=collection, data=items)
|
||||
if method == "POST":
|
||||
body = unwrap_entity(payload, element)
|
||||
oid = uuid4()
|
||||
name = str(body.get("name") or f"{element}-{oid.hex[:8]}")
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_api_objects(id, collection, name, status, data)
|
||||
VALUES($1,$2,$3,$4,$5::jsonb)""",
|
||||
oid,
|
||||
collection,
|
||||
name,
|
||||
str(body.get("status") or default_status),
|
||||
json.dumps(body),
|
||||
)
|
||||
row = await conn.fetchrow("SELECT * FROM ov_api_objects WHERE id=$1", oid)
|
||||
return respond(
|
||||
request, element=element, data=generic_entity(collection, element, row), status_code=201
|
||||
)
|
||||
if len(parts) == 2:
|
||||
oid = parts[1]
|
||||
row = await conn.fetchrow(
|
||||
"SELECT * FROM ov_api_objects WHERE id=$1::uuid AND collection=$2", oid, collection
|
||||
)
|
||||
if method == "GET":
|
||||
if row is None:
|
||||
raise OVirtError("NotFound", f"{element} not found", status_code=404)
|
||||
return respond(request, element=element, data=generic_entity(collection, element, row))
|
||||
if method == "PUT":
|
||||
if row is None:
|
||||
raise OVirtError("NotFound", f"{element} not found", status_code=404)
|
||||
body = unwrap_entity(payload, element)
|
||||
data = dict(json.loads(row["data"]) if isinstance(row["data"], str) else row["data"] or {})
|
||||
data.update(body)
|
||||
await conn.execute(
|
||||
"""UPDATE ov_api_objects SET name=COALESCE($2,name), data=$3::jsonb, updated_at=now()
|
||||
WHERE id=$1::uuid""",
|
||||
oid,
|
||||
body.get("name"),
|
||||
json.dumps(data),
|
||||
)
|
||||
row = await conn.fetchrow("SELECT * FROM ov_api_objects WHERE id=$1::uuid", oid)
|
||||
return respond(request, element=element, data=generic_entity(collection, element, row))
|
||||
if method == "DELETE":
|
||||
await conn.execute(
|
||||
"DELETE FROM ov_api_objects WHERE id=$1::uuid AND collection=$2", oid, collection
|
||||
)
|
||||
return Response(status_code=200)
|
||||
if len(parts) == 3 and method == "POST":
|
||||
from app.ovirt.jobs import respond_action
|
||||
|
||||
return await respond_action(
|
||||
request, conn, description=f"{collection} {parts[2]}"
|
||||
)
|
||||
if len(parts) >= 3:
|
||||
return await handle_subcollection(
|
||||
request, conn, method, collection, parts[1], parts[2], parts[3:], payload
|
||||
)
|
||||
raise OVirtError("NotFound", f"No handler for /{'/'.join(parts)}", status_code=404)
|
||||
|
||||
|
||||
_PARENT_OBJECT_TYPE: dict[str, str] = {
|
||||
"vms": "vm",
|
||||
"hosts": "host",
|
||||
"disks": "disk",
|
||||
"datacenters": "data_center",
|
||||
"clusters": "cluster",
|
||||
"networks": "network",
|
||||
"storagedomains": "storage_domain",
|
||||
"templates": "template",
|
||||
"users": "user",
|
||||
"groups": "group",
|
||||
"vnicprofiles": "vnic_profile",
|
||||
"vmpools": "vm_pool",
|
||||
}
|
||||
|
||||
|
||||
async def handle_subcollection(
|
||||
request: Request,
|
||||
conn: Connection,
|
||||
method: str,
|
||||
parent_collection: str,
|
||||
parent_id: str,
|
||||
sub: str,
|
||||
rest: list[str],
|
||||
payload: dict[str, Any],
|
||||
) -> Response:
|
||||
from app.ovirt.settings import OPT_DEFAULT_API_OBJECT_STATUS, option_value
|
||||
|
||||
element, _catalog_status = _meta(sub)
|
||||
default_status = await option_value(conn, OPT_DEFAULT_API_OBJECT_STATUS)
|
||||
collection_key = sub
|
||||
if not rest and method == "GET" and sub == "permissions":
|
||||
object_type = _PARENT_OBJECT_TYPE.get(parent_collection, parent_collection.rstrip("s"))
|
||||
rows = await conn.fetch(
|
||||
"""SELECT p.*, r.name AS role_name, u.name AS user_name
|
||||
FROM ov_permissions p
|
||||
JOIN ov_roles r ON r.id = p.role_id
|
||||
LEFT JOIN ov_users u ON u.id = p.user_id
|
||||
WHERE p.object_type=$1 AND p.object_id=$2::uuid
|
||||
ORDER BY r.name""",
|
||||
object_type,
|
||||
parent_id,
|
||||
)
|
||||
items = [
|
||||
{
|
||||
"id": str(r["id"]),
|
||||
"href": f"/ovirt-engine/api/{parent_collection}/{parent_id}/permissions/{r['id']}",
|
||||
"role": {"id": str(r["role_id"]), "name": r["role_name"]},
|
||||
"object": {"type": r["object_type"], "id": str(parent_id)},
|
||||
**(
|
||||
{"user": {"id": str(r["user_id"]), "name": r["user_name"]}}
|
||||
if r["user_id"] is not None
|
||||
else {}
|
||||
),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return respond(request, element="permission", collection="permissions", data=items)
|
||||
if not rest and method == "GET" and sub == "tags":
|
||||
object_type = _PARENT_OBJECT_TYPE.get(parent_collection, parent_collection.rstrip("s"))
|
||||
rows = await conn.fetch(
|
||||
"""SELECT t.*
|
||||
FROM ov_tag_assignments a
|
||||
JOIN ov_tags t ON t.id = a.tag_id
|
||||
WHERE a.object_type=$1 AND a.object_id=$2::uuid
|
||||
ORDER BY t.name""",
|
||||
object_type,
|
||||
parent_id,
|
||||
)
|
||||
items = [
|
||||
{
|
||||
"id": str(r["id"]),
|
||||
"href": f"/ovirt-engine/api/tags/{r['id']}",
|
||||
"name": r["name"],
|
||||
"description": r["description"] or "",
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return respond(request, element="tag", collection="tags", data=items)
|
||||
if not rest:
|
||||
if method == "GET":
|
||||
rows = await conn.fetch(
|
||||
"""SELECT * FROM ov_api_objects
|
||||
WHERE collection=$1 AND parent_collection=$2 AND parent_id=$3::uuid
|
||||
ORDER BY name""",
|
||||
collection_key,
|
||||
parent_collection,
|
||||
parent_id,
|
||||
)
|
||||
items = [
|
||||
generic_entity(f"{parent_collection}/{parent_id}/{sub}", element, r) for r in rows
|
||||
]
|
||||
# Fix hrefs
|
||||
for item, row in zip(items, rows, strict=False):
|
||||
item["href"] = f"/ovirt-engine/api/{parent_collection}/{parent_id}/{sub}/{row['id']}"
|
||||
return respond(request, element=element, collection=sub, data=items)
|
||||
if method == "POST":
|
||||
body = unwrap_entity(payload, element)
|
||||
oid = uuid4()
|
||||
name = str(body.get("name") or f"{element}-{oid.hex[:8]}")
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_api_objects(id, collection, name, status, parent_collection, parent_id, data)
|
||||
VALUES($1,$2,$3,$4,$5,$6::uuid,$7::jsonb)""",
|
||||
oid,
|
||||
collection_key,
|
||||
name,
|
||||
str(body.get("status") or default_status),
|
||||
parent_collection,
|
||||
parent_id,
|
||||
json.dumps(body),
|
||||
)
|
||||
row = await conn.fetchrow("SELECT * FROM ov_api_objects WHERE id=$1", oid)
|
||||
data = generic_entity(f"{parent_collection}/{parent_id}/{sub}", element, row)
|
||||
data["href"] = f"/ovirt-engine/api/{parent_collection}/{parent_id}/{sub}/{oid}"
|
||||
return respond(request, element=element, data=data, status_code=201)
|
||||
if len(rest) == 1:
|
||||
oid = rest[0]
|
||||
if method == "GET":
|
||||
row = await conn.fetchrow(
|
||||
"""SELECT * FROM ov_api_objects WHERE id=$1::uuid AND collection=$2
|
||||
AND parent_id=$3::uuid""",
|
||||
oid,
|
||||
collection_key,
|
||||
parent_id,
|
||||
)
|
||||
if row is None:
|
||||
raise OVirtError("NotFound", f"{element} not found", status_code=404)
|
||||
data = generic_entity(sub, element, row)
|
||||
data["href"] = f"/ovirt-engine/api/{parent_collection}/{parent_id}/{sub}/{oid}"
|
||||
return respond(request, element=element, data=data)
|
||||
if method == "DELETE":
|
||||
await conn.execute(
|
||||
"DELETE FROM ov_api_objects WHERE id=$1::uuid AND parent_id=$2::uuid",
|
||||
oid,
|
||||
parent_id,
|
||||
)
|
||||
return Response(status_code=200)
|
||||
if method == "PUT":
|
||||
body = unwrap_entity(payload, element)
|
||||
await conn.execute(
|
||||
"UPDATE ov_api_objects SET data=$2::jsonb, updated_at=now() WHERE id=$1::uuid",
|
||||
oid,
|
||||
json.dumps(body),
|
||||
)
|
||||
row = await conn.fetchrow("SELECT * FROM ov_api_objects WHERE id=$1::uuid", oid)
|
||||
return respond(request, element=element, data=generic_entity(sub, element, row))
|
||||
if len(rest) == 2 and method == "POST":
|
||||
from app.ovirt.jobs import respond_action
|
||||
|
||||
return await respond_action(
|
||||
request, conn, description=f"{parent_collection}/{sub} {rest[1]}"
|
||||
)
|
||||
raise OVirtError(
|
||||
"NotFound",
|
||||
f"No handler for /{parent_collection}/{parent_id}/{sub}/{'/'.join(rest)}",
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
|
||||
def remount_schema_services(app: Any, series: str) -> int:
|
||||
"""Hot-swap contract pack: reload ops and re-register OpenAPI routes."""
|
||||
|
||||
from app.ovirt.contract_loader import ensure_loaded
|
||||
from app.ovirt.registry import clear_ovirt_contract_routes, register_ovirt_contract_routes
|
||||
from app.ovirt.routes import engine
|
||||
|
||||
rt = ensure_loaded(series)
|
||||
summary = rt.reload(series)
|
||||
clear_ovirt_contract_routes(app)
|
||||
registered = 0
|
||||
if rt.pack is not None:
|
||||
registered = register_ovirt_contract_routes(app, rt.pack)
|
||||
# Re-attach catch-all after specific contract routes (match order matters).
|
||||
app.include_router(engine.router)
|
||||
app.state.ovirt_series = series
|
||||
app.state.ovirt_schema_ops = registered or summary.get("operation_count", 0)
|
||||
app.state.runtime_version = f"ovirt-{series}"
|
||||
return int(app.state.ovirt_schema_ops)
|
||||
@@ -0,0 +1,348 @@
|
||||
"""Minimal and demo seed profiles for the oVirt Engine simulator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from asyncpg import Connection
|
||||
|
||||
from app.ovirt.ids import stable_id
|
||||
from app.security.auth import hash_secret
|
||||
|
||||
MINIMAL_PROFILE = "minimal"
|
||||
DEMO_PROFILE = "demo"
|
||||
|
||||
|
||||
async def clear_ovirt_state(conn: Connection) -> None:
|
||||
tables = [
|
||||
"ov_job_steps",
|
||||
"ov_jobs",
|
||||
"ov_events",
|
||||
"ov_tag_assignments",
|
||||
"ov_tags",
|
||||
"ov_snapshots",
|
||||
"ov_nics",
|
||||
"ov_disk_attachments",
|
||||
"ov_disks",
|
||||
"ov_vms",
|
||||
"ov_templates",
|
||||
"ov_affinity_groups",
|
||||
"ov_quotas",
|
||||
"ov_vnic_profiles",
|
||||
"ov_networks",
|
||||
"ov_storage_domain_attachments",
|
||||
"ov_storage_domains",
|
||||
"ov_storage_connections",
|
||||
"ov_hosts",
|
||||
"ov_clusters",
|
||||
"ov_datacenters",
|
||||
"ov_bookmarks",
|
||||
"ov_permissions",
|
||||
"ov_tokens",
|
||||
"ov_users",
|
||||
"ov_groups",
|
||||
"ov_roles",
|
||||
"ov_domains",
|
||||
"ov_api_objects",
|
||||
"ov_demo_meta",
|
||||
]
|
||||
for table in tables:
|
||||
await conn.execute(f"TRUNCATE TABLE {table} CASCADE")
|
||||
|
||||
|
||||
async def seed_ovirt(conn: Connection) -> dict[str, Any]:
|
||||
"""Minimal lab: 1 DC, 1 cluster, 1 host, Blank template, admin user."""
|
||||
|
||||
await clear_ovirt_state(conn)
|
||||
domain_id = stable_id("domain", "internal")
|
||||
await conn.execute(
|
||||
"INSERT INTO ov_domains(id, name) VALUES($1, 'internal')", domain_id
|
||||
)
|
||||
roles = [
|
||||
(stable_id("role", "SuperUser"), "SuperUser", True),
|
||||
(stable_id("role", "UserRole"), "UserRole", False),
|
||||
(stable_id("role", "ClusterAdmin"), "ClusterAdmin", True),
|
||||
]
|
||||
for rid, name, admin in roles:
|
||||
await conn.execute(
|
||||
"INSERT INTO ov_roles(id, name, administrative) VALUES($1,$2,$3)",
|
||||
rid,
|
||||
name,
|
||||
admin,
|
||||
)
|
||||
admin_id = stable_id("user", "admin")
|
||||
pwd = hash_secret("secret", salt=b"ovirt-sim-v1-salt!")
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_users(id, domain_id, name, password_hash, enabled, principal)
|
||||
VALUES($1,$2,'admin',$3,true,'admin@internal')""",
|
||||
admin_id,
|
||||
domain_id,
|
||||
pwd,
|
||||
)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_permissions(id, role_id, user_id, object_type)
|
||||
VALUES($1,$2,$3,'system')""",
|
||||
stable_id("perm", "admin-super"),
|
||||
roles[0][0],
|
||||
admin_id,
|
||||
)
|
||||
# Extra lab users
|
||||
for uname in ("ops", "developer", "demo"):
|
||||
uid = stable_id("user", uname)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_users(id, domain_id, name, password_hash, enabled, principal)
|
||||
VALUES($1,$2,$3,$4,true,$5)""",
|
||||
uid,
|
||||
domain_id,
|
||||
uname,
|
||||
pwd,
|
||||
f"{uname}@internal",
|
||||
)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_permissions(id, role_id, user_id, object_type)
|
||||
VALUES($1,$2,$3,'system')""",
|
||||
stable_id("perm", uname),
|
||||
roles[1][0],
|
||||
uid,
|
||||
)
|
||||
|
||||
dc_id = stable_id("dc", "Default")
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_datacenters(id, name, description, status, version_major, version_minor)
|
||||
VALUES($1,'Default','Default datacenter','up',4,5)""",
|
||||
dc_id,
|
||||
)
|
||||
cluster_id = stable_id("cluster", "Default")
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_clusters(id, datacenter_id, name, description)
|
||||
VALUES($1,$2,'Default','Default cluster')""",
|
||||
cluster_id,
|
||||
dc_id,
|
||||
)
|
||||
host_id = stable_id("host", "host01")
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_hosts(id, cluster_id, name, address, status, memory, cpu_cores)
|
||||
VALUES($1,$2,'host01','192.168.1.10','up',$3,16)""",
|
||||
host_id,
|
||||
cluster_id,
|
||||
128 * 1024**3,
|
||||
)
|
||||
net_id = stable_id("net", "ovirtmgmt")
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_networks(id, datacenter_id, name, description)
|
||||
VALUES($1,$2,'ovirtmgmt','Management network')""",
|
||||
net_id,
|
||||
dc_id,
|
||||
)
|
||||
profile_id = stable_id("vnic", "ovirtmgmt")
|
||||
await conn.execute(
|
||||
"INSERT INTO ov_vnic_profiles(id, network_id, name) VALUES($1,$2,'ovirtmgmt')",
|
||||
profile_id,
|
||||
net_id,
|
||||
)
|
||||
sd_id = stable_id("sd", "data1")
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_storage_domains(id, name, type, storage_type, status, available, used)
|
||||
VALUES($1,'data1','data','nfs','active',$2,$3)""",
|
||||
sd_id,
|
||||
2 * 1024**4,
|
||||
100 * 1024**3,
|
||||
)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_storage_domain_attachments(id, storage_domain_id, datacenter_id, status)
|
||||
VALUES($1,$2,$3,'active')""",
|
||||
stable_id("sda", "data1"),
|
||||
sd_id,
|
||||
dc_id,
|
||||
)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_storage_connections(id, type, address, path)
|
||||
VALUES($1,'nfs','nfs.lab.local','/export/data1')""",
|
||||
stable_id("sc", "data1"),
|
||||
)
|
||||
blank_id = stable_id("template", "Blank")
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_templates(id, cluster_id, name, description, status, memory)
|
||||
VALUES($1,$2,'Blank','Blank template','ok',$3)""",
|
||||
blank_id,
|
||||
cluster_id,
|
||||
1024**3,
|
||||
)
|
||||
# One sample VM
|
||||
vm_id = stable_id("vm", "lab-vm-01")
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_vms(id, cluster_id, template_id, name, description, status,
|
||||
memory, cpu_sockets, cpu_cores, cpu_threads, os_type, type)
|
||||
VALUES($1,$2,$3,'lab-vm-01','Sample VM','down',$4,1,2,1,'rhel_8x64','server')""",
|
||||
vm_id,
|
||||
cluster_id,
|
||||
blank_id,
|
||||
2 * 1024**3,
|
||||
)
|
||||
disk_id = stable_id("disk", "lab-vm-01")
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_disks(id, name, provisioned_size, actual_size, storage_domain_id)
|
||||
VALUES($1,'lab-vm-01_Disk1',$2,$2,$3)""",
|
||||
disk_id,
|
||||
20 * 1024**3,
|
||||
sd_id,
|
||||
)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_disk_attachments(id, vm_id, disk_id, bootable)
|
||||
VALUES($1,$2,$3,true)""",
|
||||
stable_id("da", "lab-vm-01"),
|
||||
vm_id,
|
||||
disk_id,
|
||||
)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_nics(id, vm_id, name, mac_address, vnic_profile_id)
|
||||
VALUES($1,$2,'nic1','00:1a:4a:16:01:01',$3)""",
|
||||
stable_id("nic", "lab-vm-01"),
|
||||
vm_id,
|
||||
profile_id,
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO ov_bookmarks(id, name, value) VALUES($1,'AllVMs','Vms:')",
|
||||
stable_id("bm", "AllVMs"),
|
||||
)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_events(code, severity, description, user_id, vm_id)
|
||||
VALUES(1,'normal','Engine started',$1,$2)""",
|
||||
admin_id,
|
||||
vm_id,
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO ov_groups(id, domain_id, name) VALUES($1,$2,'engine-admins')",
|
||||
stable_id("group", "engine-admins"),
|
||||
domain_id,
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO ov_tags(id, name, description) VALUES($1,'lab','Lab tag')",
|
||||
stable_id("tag", "lab"),
|
||||
)
|
||||
job_id = stable_id("job", "seed-1")
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_jobs(id, description, status, owner_id)
|
||||
VALUES($1,'Seed inventory job','finished',$2)""",
|
||||
job_id,
|
||||
admin_id,
|
||||
)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_job_steps(id, job_id, description, status, type, number)
|
||||
VALUES($1,$2,'Finish seed','finished','executing',1)""",
|
||||
stable_id("step", "seed-1"),
|
||||
job_id,
|
||||
)
|
||||
# Surface-complete sample objects (generic collections)
|
||||
for collection, name in (
|
||||
("instancetypes", "Large"),
|
||||
("macpools", "Default"),
|
||||
("schedulingpolicies", "evenly_distributed"),
|
||||
("schedulingpolicyunits", "EvenlyDistributed"),
|
||||
("clusterlevels", "4.5"),
|
||||
("icons", "default"),
|
||||
("operatingsystems", "rhel_8x64"),
|
||||
("networkfilters", "vdsm-no-mac-spoofing"),
|
||||
("vmpools", "pool-demo"),
|
||||
("affinitylabels", "label-a"),
|
||||
("katelloerrata", "RHSA-2024:0001"),
|
||||
("externalhostproviders", "foreman-lab"),
|
||||
("openstacknetworkproviders", "ovn-provider"),
|
||||
("openstackimageproviders", "glance-lab"),
|
||||
("openstackvolumeproviders", "cinder-lab"),
|
||||
("imagetransfers", "transfer-1"),
|
||||
):
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_api_objects(id, collection, name, status, data)
|
||||
VALUES($1,$2,$3,'ok',$4::jsonb)""",
|
||||
stable_id("obj", collection, name),
|
||||
collection,
|
||||
name,
|
||||
json.dumps({"name": name, "description": f"seed {collection}"}),
|
||||
)
|
||||
|
||||
from app.ovirt.settings import seed_engine_options
|
||||
|
||||
await seed_engine_options(conn)
|
||||
|
||||
# Nested entity links + resource permissions (avoid empty subcollection GETs)
|
||||
from app.ovirt.seed_nested import seed_nested_for_inventory
|
||||
|
||||
tag_id = stable_id("tag", "lab")
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_tag_assignments(id, tag_id, object_type, object_id)
|
||||
VALUES($1,$2,'vm',$3) ON CONFLICT DO NOTHING""",
|
||||
stable_id("ta", "lab", "lab-vm-01"),
|
||||
tag_id,
|
||||
vm_id,
|
||||
)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_snapshots(id, vm_id, description, status)
|
||||
VALUES($1,$2,'seed-snapshot','ok')""",
|
||||
stable_id("snap", "lab-vm-01", "1"),
|
||||
vm_id,
|
||||
)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_quotas(id, datacenter_id, name, description)
|
||||
VALUES($1,$2,'Default','Default quota')""",
|
||||
stable_id("quota", "Default"),
|
||||
dc_id,
|
||||
)
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_affinity_groups(id, cluster_id, name, enforcing, positive)
|
||||
VALUES($1,$2,'web-affinity',true,true)""",
|
||||
stable_id("ag", "Default"),
|
||||
cluster_id,
|
||||
)
|
||||
user_ids = [
|
||||
r["id"]
|
||||
for r in await conn.fetch("SELECT id FROM ov_users ORDER BY name")
|
||||
]
|
||||
group_ids = [r["id"] for r in await conn.fetch("SELECT id FROM ov_groups ORDER BY name")]
|
||||
await seed_nested_for_inventory(
|
||||
conn,
|
||||
admin_user_id=admin_id,
|
||||
role_user_id=roles[1][0],
|
||||
datacenter_ids=[dc_id],
|
||||
cluster_ids=[cluster_id],
|
||||
host_ids=[host_id],
|
||||
network_ids=[net_id],
|
||||
storage_domain_ids=[sd_id],
|
||||
template_ids=[blank_id],
|
||||
vm_ids=[vm_id],
|
||||
disk_ids=[disk_id],
|
||||
tag_ids=[tag_id],
|
||||
user_ids=user_ids,
|
||||
group_ids=group_ids,
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"INSERT INTO ov_demo_meta(key, value) VALUES('profile', $1)", MINIMAL_PROFILE
|
||||
)
|
||||
return {
|
||||
"profile": MINIMAL_PROFILE,
|
||||
"vms": 1,
|
||||
"hosts": 1,
|
||||
"datacenters": 1,
|
||||
"users": 4,
|
||||
}
|
||||
|
||||
|
||||
async def ovirt_demo_summary(conn: Connection) -> dict[str, Any]:
|
||||
profile = await conn.fetchval("SELECT value FROM ov_demo_meta WHERE key='profile'")
|
||||
return {
|
||||
"profile": profile or MINIMAL_PROFILE,
|
||||
"loaded": profile == DEMO_PROFILE,
|
||||
"vms": await conn.fetchval("SELECT count(*) FROM ov_vms") or 0,
|
||||
"hosts": await conn.fetchval("SELECT count(*) FROM ov_hosts") or 0,
|
||||
"datacenters": await conn.fetchval("SELECT count(*) FROM ov_datacenters") or 0,
|
||||
"clusters": await conn.fetchval("SELECT count(*) FROM ov_clusters") or 0,
|
||||
"disks": await conn.fetchval("SELECT count(*) FROM ov_disks") or 0,
|
||||
"networks": await conn.fetchval("SELECT count(*) FROM ov_networks") or 0,
|
||||
"storage_domains": await conn.fetchval("SELECT count(*) FROM ov_storage_domains") or 0,
|
||||
"templates": await conn.fetchval("SELECT count(*) FROM ov_templates") or 0,
|
||||
"users": await conn.fetchval("SELECT count(*) FROM ov_users") or 0,
|
||||
"events": await conn.fetchval("SELECT count(*) FROM ov_events") or 0,
|
||||
"jobs": await conn.fetchval("SELECT count(*) FROM ov_jobs") or 0,
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"""CLI: python -m app.ovirt.seed_cli [--profile minimal|demo]."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
|
||||
import asyncpg
|
||||
|
||||
from app.ovirt.demo_datacenter import seed_ovirt_demo
|
||||
from app.ovirt.seed import seed_ovirt
|
||||
|
||||
|
||||
async def _run(profile: str) -> dict:
|
||||
dsn = os.environ.get(
|
||||
"DATABASE_URL",
|
||||
"postgresql://ovirt:ovirt@localhost:5432/ovirt_simulator",
|
||||
)
|
||||
conn = await asyncpg.connect(dsn)
|
||||
try:
|
||||
if profile == "demo":
|
||||
result = await seed_ovirt_demo(conn)
|
||||
else:
|
||||
result = await seed_ovirt(conn)
|
||||
return result
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Seed oVirt Engine simulator")
|
||||
parser.add_argument("--profile", default=os.environ.get("SEED_PROFILE", "minimal"))
|
||||
args = parser.parse_args()
|
||||
result = asyncio.run(_run(args.profile))
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,432 @@
|
||||
"""Seed nested Engine subcollections so entity `link` hrefs are non-empty.
|
||||
|
||||
Rows go into `ov_api_objects` (with parent_collection/parent_id) and into
|
||||
`ov_permissions` / `ov_tag_assignments` where specialized tables exist.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from asyncpg import Connection
|
||||
|
||||
from app.ovirt.ids import stable_id
|
||||
|
||||
|
||||
def _obj_row(
|
||||
*,
|
||||
parent_collection: str,
|
||||
parent_id: UUID,
|
||||
collection: str,
|
||||
name: str,
|
||||
data: dict[str, Any] | None = None,
|
||||
status: str = "ok",
|
||||
) -> tuple[Any, ...]:
|
||||
payload = {"name": name, **(data or {})}
|
||||
return (
|
||||
stable_id("nested", parent_collection, str(parent_id), collection, name),
|
||||
collection,
|
||||
name,
|
||||
status,
|
||||
parent_collection,
|
||||
parent_id,
|
||||
json.dumps(payload),
|
||||
)
|
||||
|
||||
|
||||
async def _insert_objs(conn: Connection, rows: list[tuple[Any, ...]]) -> None:
|
||||
if not rows:
|
||||
return
|
||||
await conn.executemany(
|
||||
"""INSERT INTO ov_api_objects(
|
||||
id, collection, name, status, parent_collection, parent_id, data
|
||||
) VALUES($1,$2,$3,$4,$5,$6::uuid,$7::jsonb)
|
||||
ON CONFLICT (id) DO NOTHING""",
|
||||
rows,
|
||||
)
|
||||
|
||||
|
||||
async def seed_nested_for_inventory(
|
||||
conn: Connection,
|
||||
*,
|
||||
admin_user_id: UUID,
|
||||
role_user_id: UUID,
|
||||
datacenter_ids: list[UUID],
|
||||
cluster_ids: list[UUID],
|
||||
host_ids: list[UUID],
|
||||
network_ids: list[UUID],
|
||||
storage_domain_ids: list[UUID],
|
||||
template_ids: list[UUID],
|
||||
vm_ids: list[UUID],
|
||||
disk_ids: list[UUID],
|
||||
tag_ids: list[UUID] | None = None,
|
||||
user_ids: list[UUID] | None = None,
|
||||
group_ids: list[UUID] | None = None,
|
||||
) -> None:
|
||||
"""Populate nested surface for the given inventory sample."""
|
||||
|
||||
obj_rows: list[tuple[Any, ...]] = []
|
||||
perm_rows: list[tuple[Any, ...]] = []
|
||||
tag_rows: list[tuple[Any, ...]] = []
|
||||
if user_ids is None:
|
||||
user_ids = []
|
||||
if group_ids is None:
|
||||
group_ids = []
|
||||
|
||||
def perm(object_type: str, object_id: UUID, key: str) -> None:
|
||||
perm_rows.append(
|
||||
(
|
||||
stable_id("perm", object_type, key),
|
||||
role_user_id,
|
||||
admin_user_id,
|
||||
object_type,
|
||||
object_id,
|
||||
)
|
||||
)
|
||||
|
||||
for dc_id in datacenter_ids:
|
||||
perm("data_center", dc_id, f"dc-{dc_id}")
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="datacenters",
|
||||
parent_id=dc_id,
|
||||
collection="qoss",
|
||||
name="default-qos",
|
||||
data={"type": "storage"},
|
||||
)
|
||||
)
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="datacenters",
|
||||
parent_id=dc_id,
|
||||
collection="iscsibonds",
|
||||
name="iscsi-bond-1",
|
||||
data={"description": "iSCSI bond"},
|
||||
)
|
||||
)
|
||||
|
||||
for cluster_id in cluster_ids:
|
||||
perm("cluster", cluster_id, f"cluster-{cluster_id}")
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="clusters",
|
||||
parent_id=cluster_id,
|
||||
collection="cpuprofiles",
|
||||
name="Default",
|
||||
data={"description": "Default CPU profile"},
|
||||
)
|
||||
)
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="clusters",
|
||||
parent_id=cluster_id,
|
||||
collection="enabledfeatures",
|
||||
name="gluster",
|
||||
data={"description": "Gluster feature"},
|
||||
)
|
||||
)
|
||||
|
||||
for host_id in host_ids:
|
||||
perm("host", host_id, f"host-{host_id}")
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="hosts",
|
||||
parent_id=host_id,
|
||||
collection="nics",
|
||||
name="eth0",
|
||||
data={"mac": {"address": "00:1a:4a:00:00:01"}, "boot_protocol": "dhcp"},
|
||||
)
|
||||
)
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="hosts",
|
||||
parent_id=host_id,
|
||||
collection="nics",
|
||||
name="eth1",
|
||||
data={"mac": {"address": "00:1a:4a:00:00:02"}, "boot_protocol": "none"},
|
||||
)
|
||||
)
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="hosts",
|
||||
parent_id=host_id,
|
||||
collection="statistics",
|
||||
name="memory.used",
|
||||
data={"unit": "bytes", "values": {"value": [{"datum": 8 * 1024**3}]}},
|
||||
)
|
||||
)
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="hosts",
|
||||
parent_id=host_id,
|
||||
collection="statistics",
|
||||
name="cpu.current.user",
|
||||
data={"unit": "percent", "values": {"value": [{"datum": 12.5}]}},
|
||||
)
|
||||
)
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="hosts",
|
||||
parent_id=host_id,
|
||||
collection="devices",
|
||||
name="pci-0000:00:1f.2",
|
||||
data={"capability": "storage", "vendor": "lab"},
|
||||
)
|
||||
)
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="hosts",
|
||||
parent_id=host_id,
|
||||
collection="hooks",
|
||||
name="before_vm_start",
|
||||
data={"event_name": "before_vm_start"},
|
||||
)
|
||||
)
|
||||
if tag_ids:
|
||||
tag_rows.append(
|
||||
(
|
||||
stable_id("ta", "host", str(host_id), str(tag_ids[0])),
|
||||
tag_ids[0],
|
||||
"host",
|
||||
host_id,
|
||||
)
|
||||
)
|
||||
|
||||
for net_id in network_ids:
|
||||
perm("network", net_id, f"net-{net_id}")
|
||||
|
||||
for sd_id in storage_domain_ids:
|
||||
perm("storage_domain", sd_id, f"sd-{sd_id}")
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="storagedomains",
|
||||
parent_id=sd_id,
|
||||
collection="files",
|
||||
name="rhel-9.iso",
|
||||
data={"type": "iso", "size": 8 * 1024**3},
|
||||
)
|
||||
)
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="storagedomains",
|
||||
parent_id=sd_id,
|
||||
collection="files",
|
||||
name="virtio-win.iso",
|
||||
data={"type": "iso", "size": 512 * 1024**2},
|
||||
)
|
||||
)
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="storagedomains",
|
||||
parent_id=sd_id,
|
||||
collection="images",
|
||||
name="base-image",
|
||||
data={"description": "Base disk image"},
|
||||
)
|
||||
)
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="storagedomains",
|
||||
parent_id=sd_id,
|
||||
collection="diskprofiles",
|
||||
name="default",
|
||||
data={"description": "Default disk profile"},
|
||||
)
|
||||
)
|
||||
|
||||
for tpl_id in template_ids:
|
||||
perm("template", tpl_id, f"tpl-{tpl_id}")
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="templates",
|
||||
parent_id=tpl_id,
|
||||
collection="nics",
|
||||
name="nic1",
|
||||
data={"interface": "virtio"},
|
||||
)
|
||||
)
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="templates",
|
||||
parent_id=tpl_id,
|
||||
collection="diskattachments",
|
||||
name="disk1",
|
||||
data={"bootable": True, "interface": "virtio_scsi"},
|
||||
)
|
||||
)
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="templates",
|
||||
parent_id=tpl_id,
|
||||
collection="cdroms",
|
||||
name="ide0",
|
||||
data={"file": {"id": "rhel-9.iso"}},
|
||||
)
|
||||
)
|
||||
|
||||
for disk_id in disk_ids:
|
||||
perm("disk", disk_id, f"disk-{disk_id}")
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="disks",
|
||||
parent_id=disk_id,
|
||||
collection="statistics",
|
||||
name="data.current.read",
|
||||
data={"unit": "bytespers", "values": {"value": [{"datum": 1024}]}},
|
||||
)
|
||||
)
|
||||
|
||||
if tag_ids:
|
||||
for user_id in user_ids:
|
||||
tag_rows.append(
|
||||
(
|
||||
stable_id("ta", "user", str(user_id), str(tag_ids[0])),
|
||||
tag_ids[0],
|
||||
"user",
|
||||
user_id,
|
||||
)
|
||||
)
|
||||
for group_id in group_ids:
|
||||
perm("group", group_id, f"group-{group_id}")
|
||||
tag_rows.append(
|
||||
(
|
||||
stable_id("ta", "group", str(group_id), str(tag_ids[0])),
|
||||
tag_ids[0],
|
||||
"group",
|
||||
group_id,
|
||||
)
|
||||
)
|
||||
else:
|
||||
for group_id in group_ids:
|
||||
perm("group", group_id, f"group-{group_id}")
|
||||
|
||||
for vm_id in vm_ids:
|
||||
perm("vm", vm_id, f"vm-{vm_id}")
|
||||
obj_rows.extend(
|
||||
[
|
||||
_obj_row(
|
||||
parent_collection="vms",
|
||||
parent_id=vm_id,
|
||||
collection="cdroms",
|
||||
name="ide0",
|
||||
data={"file": {"id": "rhel-9.iso"}},
|
||||
),
|
||||
_obj_row(
|
||||
parent_collection="vms",
|
||||
parent_id=vm_id,
|
||||
collection="graphicsconsoles",
|
||||
name="vnc",
|
||||
data={"protocol": "vnc", "port": 5900, "address": "127.0.0.1"},
|
||||
),
|
||||
_obj_row(
|
||||
parent_collection="vms",
|
||||
parent_id=vm_id,
|
||||
collection="graphicsconsoles",
|
||||
name="spice",
|
||||
data={"protocol": "spice", "port": 5901},
|
||||
),
|
||||
_obj_row(
|
||||
parent_collection="vms",
|
||||
parent_id=vm_id,
|
||||
collection="reporteddevices",
|
||||
name="eth0",
|
||||
data={"type": "network", "mac": {"address": "00:1a:4a:01:00:01"}},
|
||||
),
|
||||
_obj_row(
|
||||
parent_collection="vms",
|
||||
parent_id=vm_id,
|
||||
collection="sessions",
|
||||
name="console-1",
|
||||
data={"user": {"name": "admin@internal"}, "ip": {"address": "10.0.0.1"}},
|
||||
),
|
||||
_obj_row(
|
||||
parent_collection="vms",
|
||||
parent_id=vm_id,
|
||||
collection="applications",
|
||||
name="qemu-guest-agent",
|
||||
data={"version": "8.2.0"},
|
||||
),
|
||||
_obj_row(
|
||||
parent_collection="vms",
|
||||
parent_id=vm_id,
|
||||
collection="watchdogs",
|
||||
name="i6300esb",
|
||||
data={"model": "i6300esb", "action": "reset"},
|
||||
),
|
||||
_obj_row(
|
||||
parent_collection="vms",
|
||||
parent_id=vm_id,
|
||||
collection="statistics",
|
||||
name="memory.installed",
|
||||
data={"unit": "bytes", "values": {"value": [{"datum": 2 * 1024**3}]}},
|
||||
),
|
||||
_obj_row(
|
||||
parent_collection="vms",
|
||||
parent_id=vm_id,
|
||||
collection="numanodes",
|
||||
name="0",
|
||||
data={"index": 0, "memory": 1024**3},
|
||||
),
|
||||
_obj_row(
|
||||
parent_collection="vms",
|
||||
parent_id=vm_id,
|
||||
collection="hostdevices",
|
||||
name="pci_0000_00_02_0",
|
||||
data={"capability": "pci"},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
# Scheduling policy children + role permits
|
||||
for sp_name in ("evenly_distributed", "power_saving", "vm_evenly_distributed"):
|
||||
sp_id = await conn.fetchval(
|
||||
"SELECT id FROM ov_api_objects WHERE collection='schedulingpolicies' AND name=$1",
|
||||
sp_name,
|
||||
)
|
||||
if sp_id is None:
|
||||
continue
|
||||
for sub, child in (
|
||||
("filters", "Memory"),
|
||||
("weights", "EvenlyDistributed"),
|
||||
("balances", "EvenlyDistributed"),
|
||||
):
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="schedulingpolicies",
|
||||
parent_id=sp_id,
|
||||
collection=sub,
|
||||
name=child,
|
||||
data={"factor": 1},
|
||||
)
|
||||
)
|
||||
for role_name in ("SuperUser", "UserRole", "ClusterAdmin"):
|
||||
role_id = await conn.fetchval("SELECT id FROM ov_roles WHERE name=$1", role_name)
|
||||
if role_id is None:
|
||||
continue
|
||||
for permit in ("create_vm", "login", "manipulate_vm"):
|
||||
obj_rows.append(
|
||||
_obj_row(
|
||||
parent_collection="roles",
|
||||
parent_id=role_id,
|
||||
collection="permits",
|
||||
name=permit,
|
||||
data={"administrative": role_name != "UserRole"},
|
||||
)
|
||||
)
|
||||
|
||||
if perm_rows:
|
||||
await conn.executemany(
|
||||
"""INSERT INTO ov_permissions(id, role_id, user_id, object_type, object_id)
|
||||
VALUES($1,$2,$3,$4,$5::uuid)
|
||||
ON CONFLICT (id) DO NOTHING""",
|
||||
perm_rows,
|
||||
)
|
||||
await _insert_objs(conn, obj_rows)
|
||||
if tag_rows:
|
||||
await conn.executemany(
|
||||
"""INSERT INTO ov_tag_assignments(id, tag_id, object_type, object_id)
|
||||
VALUES($1,$2,$3,$4::uuid) ON CONFLICT DO NOTHING""",
|
||||
tag_rows,
|
||||
)
|
||||
@@ -0,0 +1,173 @@
|
||||
"""XML and JSON representation helpers for oVirt Engine API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from xml.etree.ElementTree import Element, SubElement, tostring
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
|
||||
_XML_DECL = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
|
||||
|
||||
def wants_xml(request: Request) -> bool:
|
||||
accept = (request.headers.get("accept") or "").lower()
|
||||
if "application/json" in accept or "+json" in accept:
|
||||
return False
|
||||
if "application/xml" in accept or "text/xml" in accept or "+xml" in accept:
|
||||
return True
|
||||
# Engine historically defaults to XML
|
||||
return True
|
||||
|
||||
|
||||
def parse_body(raw: bytes, content_type: str | None) -> dict[str, Any]:
|
||||
if not raw:
|
||||
return {}
|
||||
ct = (content_type or "").lower()
|
||||
text = raw.decode("utf-8", errors="replace").strip()
|
||||
if not text:
|
||||
return {}
|
||||
if "json" in ct or text.startswith("{") or text.startswith("["):
|
||||
data = json.loads(text)
|
||||
return data if isinstance(data, dict) else {"value": data}
|
||||
# Minimal XML → dict (element children)
|
||||
try:
|
||||
from xml.etree.ElementTree import fromstring
|
||||
|
||||
root = fromstring(text)
|
||||
return {root.tag: _xml_node(root)}
|
||||
except Exception:
|
||||
return {"raw": text}
|
||||
|
||||
|
||||
def _xml_node(el: Element) -> Any:
|
||||
children = list(el)
|
||||
data: dict[str, Any] = {}
|
||||
if el.attrib:
|
||||
data.update({f"@{k}": v for k, v in el.attrib.items()})
|
||||
if not children:
|
||||
text = (el.text or "").strip()
|
||||
if data:
|
||||
if text:
|
||||
data["#text"] = text
|
||||
return data
|
||||
return text
|
||||
for child in children:
|
||||
value = _xml_node(child)
|
||||
if child.tag in data:
|
||||
existing = data[child.tag]
|
||||
if not isinstance(existing, list):
|
||||
data[child.tag] = [existing]
|
||||
data[child.tag].append(value)
|
||||
else:
|
||||
data[child.tag] = value
|
||||
return data
|
||||
|
||||
|
||||
def unwrap_entity(payload: dict[str, Any], element: str) -> dict[str, Any]:
|
||||
if element in payload and isinstance(payload[element], dict):
|
||||
return dict(payload[element])
|
||||
# XML parse nests under root tag
|
||||
if len(payload) == 1:
|
||||
only = next(iter(payload.values()))
|
||||
if isinstance(only, dict):
|
||||
return dict(only)
|
||||
return dict(payload)
|
||||
|
||||
|
||||
def _to_xml_value(parent: Element, key: str, value: Any) -> None:
|
||||
if value is None:
|
||||
return
|
||||
if isinstance(value, dict):
|
||||
attrs = {k[1:]: str(v) for k, v in value.items() if k.startswith("@")}
|
||||
body = {k: v for k, v in value.items() if not k.startswith("@")}
|
||||
node = SubElement(parent, key, attrs)
|
||||
if "#text" in body and len(body) == 1:
|
||||
node.text = str(body["#text"])
|
||||
return
|
||||
for ck, cv in body.items():
|
||||
if ck == "#text":
|
||||
node.text = str(cv)
|
||||
else:
|
||||
_to_xml_value(node, ck, cv)
|
||||
return
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
_to_xml_value(parent, key, item)
|
||||
return
|
||||
if isinstance(value, bool):
|
||||
SubElement(parent, key).text = "true" if value else "false"
|
||||
return
|
||||
if isinstance(value, datetime):
|
||||
SubElement(parent, key).text = value.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
||||
return
|
||||
SubElement(parent, key).text = str(value)
|
||||
|
||||
|
||||
def entity_to_xml(element: str, data: dict[str, Any]) -> str:
|
||||
attrs = {}
|
||||
if "id" in data:
|
||||
attrs["id"] = str(data["id"])
|
||||
if "href" in data:
|
||||
attrs["href"] = str(data["href"])
|
||||
root = Element(element, attrs)
|
||||
for key, value in data.items():
|
||||
if key in {"id", "href"}:
|
||||
continue
|
||||
_to_xml_value(root, key, value)
|
||||
return _XML_DECL + tostring(root, encoding="unicode")
|
||||
|
||||
|
||||
def collection_to_xml(collection: str, element: str, items: list[dict[str, Any]]) -> str:
|
||||
root = Element(collection)
|
||||
for item in items:
|
||||
child_xml = entity_to_xml(element, item)
|
||||
# strip decl and wrap
|
||||
from xml.etree.ElementTree import fromstring
|
||||
|
||||
root.append(fromstring(re.sub(r"^<\?xml[^?]*\?>", "", child_xml)))
|
||||
return _XML_DECL + tostring(root, encoding="unicode")
|
||||
|
||||
|
||||
def entity_to_json(element: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
return {element: data}
|
||||
|
||||
|
||||
def collection_to_json(element: str, items: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
# Engine JSON uses singular key with array
|
||||
return {element: items}
|
||||
|
||||
|
||||
def respond(
|
||||
request: Request,
|
||||
*,
|
||||
element: str,
|
||||
data: dict[str, Any] | list[dict[str, Any]] | None = None,
|
||||
collection: str | None = None,
|
||||
status_code: int = 200,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> Response:
|
||||
as_xml = wants_xml(request)
|
||||
hdrs = dict(headers or {})
|
||||
if collection is not None and isinstance(data, list):
|
||||
if as_xml:
|
||||
body = collection_to_xml(collection, element, data)
|
||||
return Response(content=body, status_code=status_code, media_type="application/xml", headers=hdrs)
|
||||
return JSONResponse(
|
||||
content=collection_to_json(element, data),
|
||||
status_code=status_code,
|
||||
headers=hdrs,
|
||||
)
|
||||
payload = data if isinstance(data, dict) else {}
|
||||
if as_xml:
|
||||
return Response(
|
||||
content=entity_to_xml(element, payload),
|
||||
status_code=status_code,
|
||||
media_type="application/xml",
|
||||
headers=hdrs,
|
||||
)
|
||||
return JSONResponse(content=entity_to_json(element, payload), status_code=status_code, headers=hdrs)
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Engine runtime settings stored in `ov_api_objects` (collection=options)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from asyncpg import Connection
|
||||
|
||||
from app.ovirt.ids import stable_id
|
||||
|
||||
# Seeded option names (values live only in Postgres).
|
||||
OPT_DEFAULT_VM_MEMORY = "DEFAULT_VM_MEMORY"
|
||||
OPT_DEFAULT_DISK_SIZE = "DEFAULT_DISK_SIZE"
|
||||
OPT_DEFAULT_HOST_MEMORY = "DEFAULT_HOST_MEMORY"
|
||||
OPT_DEFAULT_HOST_CPU_CORES = "DEFAULT_HOST_CPU_CORES"
|
||||
OPT_DEFAULT_CPU_SOCKETS = "DEFAULT_CPU_SOCKETS"
|
||||
OPT_DEFAULT_CPU_CORES = "DEFAULT_CPU_CORES"
|
||||
OPT_DEFAULT_CPU_THREADS = "DEFAULT_CPU_THREADS"
|
||||
OPT_DEFAULT_NIC_INTERFACE = "DEFAULT_NIC_INTERFACE"
|
||||
OPT_DEFAULT_DISK_INTERFACE = "DEFAULT_DISK_INTERFACE"
|
||||
OPT_DEFAULT_DISK_FORMAT = "DEFAULT_DISK_FORMAT"
|
||||
OPT_DEFAULT_VM_TYPE = "DEFAULT_VM_TYPE"
|
||||
OPT_DEFAULT_OS_TYPE = "DEFAULT_OS_TYPE"
|
||||
OPT_DEFAULT_VM_STATUS = "DEFAULT_VM_STATUS"
|
||||
OPT_DEFAULT_HOST_STATUS = "DEFAULT_HOST_STATUS"
|
||||
OPT_DEFAULT_DC_STATUS = "DEFAULT_DC_STATUS"
|
||||
OPT_DEFAULT_SD_STATUS = "DEFAULT_SD_STATUS"
|
||||
OPT_DEFAULT_HOST_ADDRESS = "DEFAULT_HOST_ADDRESS"
|
||||
OPT_DEFAULT_CLUSTER_CPU_TYPE = "DEFAULT_CLUSTER_CPU_TYPE"
|
||||
OPT_DEFAULT_SD_TYPE = "DEFAULT_SD_TYPE"
|
||||
OPT_DEFAULT_STORAGE_TYPE = "DEFAULT_STORAGE_TYPE"
|
||||
OPT_DEFAULT_SD_AVAILABLE = "DEFAULT_SD_AVAILABLE"
|
||||
OPT_DEFAULT_STORAGE_CONNECTION_TYPE = "DEFAULT_STORAGE_CONNECTION_TYPE"
|
||||
OPT_DEFAULT_MAC_PREFIX = "DEFAULT_MAC_PREFIX"
|
||||
OPT_DEFAULT_NIC_NAME = "DEFAULT_NIC_NAME"
|
||||
OPT_DEFAULT_SNAPSHOT_DESCRIPTION = "DEFAULT_SNAPSHOT_DESCRIPTION"
|
||||
OPT_DEFAULT_TAG_NAME = "DEFAULT_TAG_NAME"
|
||||
OPT_DEFAULT_API_OBJECT_STATUS = "DEFAULT_API_OBJECT_STATUS"
|
||||
OPT_DEFAULT_ACTION_STATUS = "DEFAULT_ACTION_STATUS"
|
||||
OPT_DEFAULT_JOB_STATUS_COMPLETE = "DEFAULT_JOB_STATUS_COMPLETE"
|
||||
OPT_DEFAULT_JOB_STATUS_STARTED = "DEFAULT_JOB_STATUS_STARTED"
|
||||
OPT_DEFAULT_JOB_STEP_TYPE = "DEFAULT_JOB_STEP_TYPE"
|
||||
OPT_DEFAULT_AUTH_DOMAIN = "DEFAULT_AUTH_DOMAIN"
|
||||
OPT_DEFAULT_API_SCOPE = "DEFAULT_API_SCOPE"
|
||||
OPT_DEFAULT_TOKEN_TYPE = "DEFAULT_TOKEN_TYPE"
|
||||
OPT_DEFAULT_USER_ROLE = "DEFAULT_USER_ROLE"
|
||||
OPT_OAUTH_TOKEN_TTL_SECONDS = "OAUTH_TOKEN_TTL_SECONDS"
|
||||
OPT_BASIC_SESSION_TTL_SECONDS = "BASIC_SESSION_TTL_SECONDS"
|
||||
OPT_SD_ATTACH_ACTIVE = "SD_ATTACH_STATUS_ACTIVE"
|
||||
OPT_SD_ATTACH_MAINTENANCE = "SD_ATTACH_STATUS_MAINTENANCE"
|
||||
OPT_PRODUCT_NAME = "PRODUCT_NAME"
|
||||
OPT_PRODUCT_VENDOR = "PRODUCT_VENDOR"
|
||||
OPT_PRODUCT_MAJOR = "PRODUCT_MAJOR"
|
||||
OPT_PRODUCT_MINOR = "PRODUCT_MINOR"
|
||||
OPT_PRODUCT_BUILD = "PRODUCT_BUILD"
|
||||
OPT_PRODUCT_REVISION = "PRODUCT_REVISION"
|
||||
OPT_PRODUCT_FULL = "PRODUCT_FULL"
|
||||
OPT_ENGINE_API_DEFAULT_VERSION = "ENGINE_API_DEFAULT_VERSION"
|
||||
OPT_VM_ACTION_STATUS_MAP = "VM_ACTION_STATUS_MAP"
|
||||
OPT_HOST_ACTION_STATUS_MAP = "HOST_ACTION_STATUS_MAP"
|
||||
|
||||
_VM_ACTION_MAP = {
|
||||
"start": "up",
|
||||
"stop": "down",
|
||||
"shutdown": "down",
|
||||
"reboot": "up",
|
||||
"suspend": "suspended",
|
||||
"migrate": "up",
|
||||
"cancelmigration": "up",
|
||||
"maintenance": "down",
|
||||
"logon": "up",
|
||||
"freeze_filesystems": "up",
|
||||
"thaw_filesystems": "up",
|
||||
}
|
||||
|
||||
_HOST_ACTION_MAP = {
|
||||
"activate": "up",
|
||||
"deactivate": "maintenance",
|
||||
"approve": "up",
|
||||
"install": "up",
|
||||
"fence": "down",
|
||||
"refresh": "up",
|
||||
"upgrade": "up",
|
||||
"upgradecheck": "up",
|
||||
"commitnetconfig": "up",
|
||||
"enrollcertificate": "up",
|
||||
"iscsidiscover": "up",
|
||||
"iscsilogin": "up",
|
||||
"unregisteredstoragedomainsdiscover": "up",
|
||||
}
|
||||
|
||||
_DEFAULT_OPTIONS: list[tuple[str, str]] = [
|
||||
(OPT_DEFAULT_VM_MEMORY, str(1024**3)),
|
||||
(OPT_DEFAULT_DISK_SIZE, str(10 * 1024**3)),
|
||||
(OPT_DEFAULT_HOST_MEMORY, str(64 * 1024**3)),
|
||||
(OPT_DEFAULT_HOST_CPU_CORES, "16"),
|
||||
(OPT_DEFAULT_CPU_SOCKETS, "1"),
|
||||
(OPT_DEFAULT_CPU_CORES, "1"),
|
||||
(OPT_DEFAULT_CPU_THREADS, "1"),
|
||||
(OPT_DEFAULT_NIC_INTERFACE, "virtio"),
|
||||
(OPT_DEFAULT_DISK_INTERFACE, "virtio_scsi"),
|
||||
(OPT_DEFAULT_DISK_FORMAT, "cow"),
|
||||
(OPT_DEFAULT_VM_TYPE, "server"),
|
||||
(OPT_DEFAULT_OS_TYPE, "other"),
|
||||
(OPT_DEFAULT_VM_STATUS, "down"),
|
||||
(OPT_DEFAULT_HOST_STATUS, "up"),
|
||||
(OPT_DEFAULT_DC_STATUS, "up"),
|
||||
(OPT_DEFAULT_SD_STATUS, "active"),
|
||||
(OPT_DEFAULT_HOST_ADDRESS, "127.0.0.1"),
|
||||
(OPT_DEFAULT_CLUSTER_CPU_TYPE, "Intel Conroe Family"),
|
||||
(OPT_DEFAULT_SD_TYPE, "data"),
|
||||
(OPT_DEFAULT_STORAGE_TYPE, "nfs"),
|
||||
(OPT_DEFAULT_SD_AVAILABLE, str(1024**4)),
|
||||
(OPT_DEFAULT_STORAGE_CONNECTION_TYPE, "nfs"),
|
||||
(OPT_DEFAULT_MAC_PREFIX, "00:1a:4a"),
|
||||
(OPT_DEFAULT_NIC_NAME, "nic1"),
|
||||
(OPT_DEFAULT_SNAPSHOT_DESCRIPTION, "snapshot"),
|
||||
(OPT_DEFAULT_TAG_NAME, "tag"),
|
||||
(OPT_DEFAULT_API_OBJECT_STATUS, "ok"),
|
||||
(OPT_DEFAULT_ACTION_STATUS, "complete"),
|
||||
(OPT_DEFAULT_JOB_STATUS_COMPLETE, "finished"),
|
||||
(OPT_DEFAULT_JOB_STATUS_STARTED, "started"),
|
||||
(OPT_DEFAULT_JOB_STEP_TYPE, "executing"),
|
||||
(OPT_DEFAULT_AUTH_DOMAIN, "internal"),
|
||||
(OPT_DEFAULT_API_SCOPE, "ovirt-app-api"),
|
||||
(OPT_DEFAULT_TOKEN_TYPE, "bearer"),
|
||||
(OPT_DEFAULT_USER_ROLE, "UserRole"),
|
||||
(OPT_OAUTH_TOKEN_TTL_SECONDS, "3600"),
|
||||
(OPT_BASIC_SESSION_TTL_SECONDS, "7200"),
|
||||
(OPT_SD_ATTACH_ACTIVE, "active"),
|
||||
(OPT_SD_ATTACH_MAINTENANCE, "maintenance"),
|
||||
(OPT_PRODUCT_NAME, "oVirt Engine"),
|
||||
(OPT_PRODUCT_VENDOR, "ovirt.org"),
|
||||
(OPT_PRODUCT_MAJOR, "4"),
|
||||
(OPT_PRODUCT_MINOR, "5"),
|
||||
(OPT_PRODUCT_BUILD, "0"),
|
||||
(OPT_PRODUCT_REVISION, "0"),
|
||||
(OPT_PRODUCT_FULL, "4.5.0"),
|
||||
(OPT_ENGINE_API_DEFAULT_VERSION, "4"),
|
||||
(OPT_VM_ACTION_STATUS_MAP, json.dumps(_VM_ACTION_MAP, separators=(",", ":"))),
|
||||
(OPT_HOST_ACTION_STATUS_MAP, json.dumps(_HOST_ACTION_MAP, separators=(",", ":"))),
|
||||
]
|
||||
|
||||
|
||||
async def seed_engine_options(conn: Connection) -> None:
|
||||
"""Upsert Engine options used by handlers (create defaults + product_info)."""
|
||||
|
||||
for name, value in _DEFAULT_OPTIONS:
|
||||
await conn.execute(
|
||||
"""INSERT INTO ov_api_objects(id, collection, name, status, data)
|
||||
VALUES($1,'options',$2,'ok',$3::jsonb)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
data=EXCLUDED.data, name=EXCLUDED.name, status='ok', updated_at=now()""",
|
||||
stable_id("obj", "options", name),
|
||||
name,
|
||||
json.dumps({"name": name, "value": value, "description": f"engine option {name}"}),
|
||||
)
|
||||
|
||||
|
||||
async def option_value(conn: Connection, name: str) -> str:
|
||||
row = await conn.fetchrow(
|
||||
"""SELECT data FROM ov_api_objects
|
||||
WHERE collection='options' AND name=$1""",
|
||||
name,
|
||||
)
|
||||
if row is None:
|
||||
raise RuntimeError(f"missing engine option in DB: {name} (reload seed)")
|
||||
data = row["data"]
|
||||
if isinstance(data, str):
|
||||
data = json.loads(data)
|
||||
data = dict(data or {})
|
||||
if "value" in data and data["value"] is not None:
|
||||
return str(data["value"])
|
||||
raise RuntimeError(f"engine option {name} has no value in DB")
|
||||
|
||||
|
||||
async def option_int(conn: Connection, name: str) -> int:
|
||||
return int(await option_value(conn, name))
|
||||
|
||||
|
||||
async def option_json(conn: Connection, name: str) -> Any:
|
||||
raw = await option_value(conn, name)
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
async def product_info_from_db(conn: Connection) -> dict[str, Any]:
|
||||
return {
|
||||
"name": await option_value(conn, OPT_PRODUCT_NAME),
|
||||
"vendor": await option_value(conn, OPT_PRODUCT_VENDOR),
|
||||
"version": {
|
||||
"major": await option_value(conn, OPT_PRODUCT_MAJOR),
|
||||
"minor": await option_value(conn, OPT_PRODUCT_MINOR),
|
||||
"build": await option_value(conn, OPT_PRODUCT_BUILD),
|
||||
"revision": await option_value(conn, OPT_PRODUCT_REVISION),
|
||||
"full_version": await option_value(conn, OPT_PRODUCT_FULL),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Resolve Engine API version (v3/v4) and active series pack."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from app.ovirt.errors import OVirtError
|
||||
|
||||
_VERSION_PREFIX = re.compile(r"^/ovirt-engine/api/(v[34])(/.*)?$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApiVersionContext:
|
||||
api_version: str # "3" or "4"
|
||||
series: str
|
||||
path_suffix: str # path after /ovirt-engine/api[/vN]
|
||||
|
||||
|
||||
def resolve_api_version(request: Request, default_series: str = "4.5") -> ApiVersionContext:
|
||||
path = request.url.path
|
||||
header = (request.headers.get("version") or "").strip()
|
||||
series = getattr(request.app.state, "ovirt_series", None) or default_series
|
||||
|
||||
m = _VERSION_PREFIX.match(path)
|
||||
if m:
|
||||
ver = m.group(1)[1:] # strip v
|
||||
rest = m.group(2) or ""
|
||||
return ApiVersionContext(api_version=ver, series=series, path_suffix=rest or "/")
|
||||
|
||||
if path.startswith("/ovirt-engine/api"):
|
||||
rest = path[len("/ovirt-engine/api") :] or "/"
|
||||
if header in {"3", "4"}:
|
||||
return ApiVersionContext(api_version=header, series=series, path_suffix=rest)
|
||||
# default version 4 for 4.x series, 3 for 3.x
|
||||
default = "3" if str(series).startswith("3.") else "4"
|
||||
return ApiVersionContext(api_version=default, series=series, path_suffix=rest)
|
||||
|
||||
raise OVirtError("NotFound", f"Unknown path {path}", status_code=404)
|
||||
|
||||
|
||||
def strip_api_prefix(path: str) -> str:
|
||||
"""Normalize to collection-relative path starting with /."""
|
||||
|
||||
for prefix in (
|
||||
"/ovirt-engine/api/v4",
|
||||
"/ovirt-engine/api/v3",
|
||||
"/ovirt-engine/api",
|
||||
):
|
||||
if path.startswith(prefix):
|
||||
rest = path[len(prefix) :] or "/"
|
||||
return rest if rest.startswith("/") else f"/{rest}"
|
||||
return path
|
||||
@@ -0,0 +1 @@
|
||||
"""Authentication, secrets, and authorization boundaries."""
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Password hashing helpers for lab users."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
|
||||
|
||||
def _b64(value: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(value).rstrip(b"=").decode()
|
||||
|
||||
|
||||
def _unb64(value: str) -> bytes:
|
||||
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
||||
|
||||
|
||||
def hash_secret(secret: str, *, salt: bytes | None = None) -> str:
|
||||
actual_salt = salt or secrets.token_bytes(16)
|
||||
digest = hashlib.scrypt(secret.encode(), salt=actual_salt, n=2**14, r=8, p=1, dklen=32)
|
||||
return f"scrypt$16384$8$1${_b64(actual_salt)}${_b64(digest)}"
|
||||
|
||||
|
||||
def verify_secret(secret: str, encoded: str) -> bool:
|
||||
try:
|
||||
algorithm, n, r, p, salt, expected = encoded.split("$")
|
||||
if algorithm != "scrypt":
|
||||
return False
|
||||
actual = hashlib.scrypt(
|
||||
secret.encode(), salt=_unb64(salt), n=int(n), r=int(r), p=int(p), dklen=32
|
||||
)
|
||||
return hmac.compare_digest(actual, _unb64(expected))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Static web console assets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
_WEB_ROOT = Path(__file__).parent
|
||||
_CONSOLE_HTML = _WEB_ROOT / "index.html"
|
||||
_STATIC = _WEB_ROOT / "static"
|
||||
|
||||
|
||||
def console_html() -> str:
|
||||
"""Return the latest console markup from disk."""
|
||||
|
||||
return _CONSOLE_HTML.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def static_path(name: str) -> Path | None:
|
||||
path = _STATIC / name
|
||||
return path if path.is_file() else None
|
||||
+7183
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,328 @@
|
||||
"""Build UI catalog / method / compatibility payloads from oVirt contract packs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.ovirt.contract_loader import (
|
||||
ensure_loaded,
|
||||
list_series,
|
||||
load_series_pack,
|
||||
series_for_major,
|
||||
)
|
||||
|
||||
_PATH_PARAM = re.compile(r"\{([^{}]+)\}")
|
||||
|
||||
_PATH_PARAM_EXAMPLES: dict[str, object] = {
|
||||
"vm": "vm-001",
|
||||
"vmId": "00000000-0000-0000-0000-000000000001",
|
||||
"host": "host-01",
|
||||
"hostId": "00000000-0000-0000-0000-000000000011",
|
||||
"cluster": "Default",
|
||||
"clusterId": "00000000-0000-0000-0000-000000000021",
|
||||
"dataCenter": "Default",
|
||||
"dataCenterId": "00000000-0000-0000-0000-000000000031",
|
||||
"disk": "disk-001",
|
||||
"diskId": "00000000-0000-0000-0000-000000000041",
|
||||
"network": "ovirtmgmt",
|
||||
"networkId": "00000000-0000-0000-0000-000000000051",
|
||||
"storageDomain": "data",
|
||||
"storageDomainId": "00000000-0000-0000-0000-000000000061",
|
||||
"template": "Blank",
|
||||
"templateId": "00000000-0000-0000-0000-000000000071",
|
||||
"user": "admin@internal",
|
||||
"userId": "00000000-0000-0000-0000-000000000081",
|
||||
"jobId": "00000000-0000-0000-0000-000000000091",
|
||||
"id": "00000000-0000-0000-0000-000000000001",
|
||||
}
|
||||
|
||||
|
||||
def path_param_example(name: str) -> object | None:
|
||||
"""Return a realistic placeholder for a common Engine path parameter."""
|
||||
|
||||
return _PATH_PARAM_EXAMPLES.get(name)
|
||||
|
||||
_SERIES_LABELS = {
|
||||
"3.0": "Engine 3.0",
|
||||
"3.1": "Engine 3.1",
|
||||
"3.2": "Engine 3.2",
|
||||
"3.3": "Engine 3.3",
|
||||
"3.4": "Engine 3.4",
|
||||
"3.5": "Engine 3.5",
|
||||
"3.6": "Engine 3.6",
|
||||
"4.3": "Engine 4.3",
|
||||
"4.4": "Engine 4.4",
|
||||
"4.5": "Engine 4.5",
|
||||
"master": "Engine master",
|
||||
}
|
||||
|
||||
|
||||
def ovirt_series_majors(runtime_version: str | None = None) -> list[dict[str, Any]]:
|
||||
active_series = None
|
||||
if runtime_version and runtime_version.startswith("ovirt-"):
|
||||
active_series = runtime_version.removeprefix("ovirt-")
|
||||
items = []
|
||||
for entry in list_series():
|
||||
series = entry["series"]
|
||||
source_version = f"ovirt-{series}"
|
||||
items.append(
|
||||
{
|
||||
"major": entry["major"],
|
||||
"series": series,
|
||||
"label": _SERIES_LABELS.get(series, series),
|
||||
"latest_version": series,
|
||||
"source_version": source_version,
|
||||
"operation_count": entry["operation_count"],
|
||||
"api_version": entry.get("api_version", "4"),
|
||||
"active": series == active_series,
|
||||
"deltas": entry.get("deltas", {}),
|
||||
# Local packs ship in-repo under contracts/ovirt/<series>/.
|
||||
"bundled": True,
|
||||
"artifact_url": f"contracts/ovirt/{series}",
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def ovirt_catalog_payload(major: int) -> dict[str, Any]:
|
||||
series = series_for_major(major)
|
||||
ensure_loaded(series)
|
||||
pack = load_series_pack(series)
|
||||
by_path: dict[str, list[dict[str, Any]]] = {}
|
||||
for op in pack.operations:
|
||||
by_path.setdefault(op.path, []).append(
|
||||
{
|
||||
"verb": op.method,
|
||||
"name": op.operation_id,
|
||||
"description": op.notes or f"{op.kind} {op.resource_type}",
|
||||
"protected": op.requires_auth,
|
||||
"implemented": True,
|
||||
}
|
||||
)
|
||||
paths = [
|
||||
{"path": path, "methods": methods}
|
||||
for path, methods in sorted(by_path.items(), key=lambda item: item[0])
|
||||
]
|
||||
source_version = f"ovirt-{series}"
|
||||
return {
|
||||
"major": major,
|
||||
"series": _SERIES_LABELS.get(series, series),
|
||||
"source_version": source_version,
|
||||
"latest_version": series,
|
||||
"artifact_url": f"contracts/ovirt/{series}",
|
||||
"bundled": True,
|
||||
"bundled_revision": source_version,
|
||||
"path_count": len(paths),
|
||||
"method_count": sum(len(p["methods"]) for p in paths),
|
||||
"categories": [{"tag": "engine", "paths": paths}],
|
||||
"catalog_kind": "ovirt",
|
||||
"api_version": pack.api_version,
|
||||
"deltas": next((s.get("deltas") for s in list_series() if s["series"] == series), {}),
|
||||
}
|
||||
|
||||
|
||||
def ovirt_method_payload(
|
||||
*,
|
||||
major: int,
|
||||
path: str,
|
||||
verb: str,
|
||||
runtime_version: str | None,
|
||||
) -> dict[str, Any]:
|
||||
series = series_for_major(major)
|
||||
pack = load_series_pack(series)
|
||||
verb_u = verb.upper()
|
||||
for op in pack.operations:
|
||||
if op.path == path and op.method == verb_u:
|
||||
path_params = _PATH_PARAM.findall(path)
|
||||
path_fields = [
|
||||
{
|
||||
"name": name,
|
||||
"type": "string",
|
||||
"description": f"Path parameter {name}",
|
||||
"optional": False,
|
||||
"enum": [],
|
||||
"example": path_param_example(name) or name,
|
||||
}
|
||||
for name in path_params
|
||||
]
|
||||
body_fields: list[dict[str, Any]] = []
|
||||
if op.method in {"POST", "PUT"} and op.kind in {"collection", "item", "action"}:
|
||||
body_fields.append(
|
||||
{
|
||||
"name": op.element,
|
||||
"type": "object",
|
||||
"description": f"{op.element} payload (XML or JSON)",
|
||||
"optional": op.kind == "action",
|
||||
"enum": [],
|
||||
"example": {op.element: {"name": "example"}},
|
||||
}
|
||||
)
|
||||
query_fields = []
|
||||
if op.search:
|
||||
query_fields.extend(
|
||||
[
|
||||
{
|
||||
"name": "search",
|
||||
"type": "string",
|
||||
"description": "Engine search query (e.g. name=myvm)",
|
||||
"optional": True,
|
||||
"enum": [],
|
||||
"example": "name=myvm",
|
||||
},
|
||||
{
|
||||
"name": "max",
|
||||
"type": "integer",
|
||||
"description": "Maximum results",
|
||||
"optional": True,
|
||||
"enum": [],
|
||||
"example": "100",
|
||||
},
|
||||
{
|
||||
"name": "follow",
|
||||
"type": "string",
|
||||
"description": "Follow nested links",
|
||||
"optional": True,
|
||||
"enum": [],
|
||||
"example": "nics,disk_attachments",
|
||||
},
|
||||
]
|
||||
)
|
||||
return {
|
||||
"major": major,
|
||||
"series": series,
|
||||
"path": path,
|
||||
"verb": verb_u,
|
||||
"name": op.operation_id,
|
||||
"description": op.notes,
|
||||
"implemented": True,
|
||||
"protected": op.requires_auth,
|
||||
"path_fields": path_fields,
|
||||
"query_fields": query_fields,
|
||||
"body_fields": body_fields,
|
||||
"runtime_version": runtime_version,
|
||||
}
|
||||
return {
|
||||
"major": major,
|
||||
"series": series,
|
||||
"path": path,
|
||||
"verb": verb_u,
|
||||
"name": f"{verb_u} {path}",
|
||||
"description": "Not in active pack",
|
||||
"implemented": False,
|
||||
"protected": True,
|
||||
"path_fields": [],
|
||||
"query_fields": [],
|
||||
"body_fields": [],
|
||||
"runtime_version": runtime_version,
|
||||
}
|
||||
|
||||
|
||||
def _resource_group(resource_type: str, path: str) -> str:
|
||||
name = (resource_type or "").strip().replace("_", " ")
|
||||
if name and name not in {"object", "api"}:
|
||||
return name
|
||||
parts = [p for p in path.strip("/").split("/") if p and not p.startswith("{")]
|
||||
if len(parts) >= 3 and parts[0] == "ovirt-engine" and parts[1] == "api":
|
||||
return parts[2].replace("_", " ")
|
||||
return parts[-1].replace("_", " ") if parts else "root"
|
||||
|
||||
|
||||
def ovirt_compatibility_payload(
|
||||
*,
|
||||
major: int,
|
||||
runtime_version: str | None,
|
||||
schema_ops_mounted: int | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Compatibility report shaped for Help → Compatibility and catalog coverage meters."""
|
||||
|
||||
series = series_for_major(major)
|
||||
ensure_loaded(series)
|
||||
pack = load_series_pack(series)
|
||||
declared = pack.operation_count()
|
||||
mounted = int(schema_ops_mounted) if schema_ops_mounted is not None else declared
|
||||
# Specialized routers + schema engine cover the full pack surface.
|
||||
implemented = declared if mounted >= max(1, int(declared * 0.9)) else min(mounted, declared)
|
||||
coverage = (implemented / declared) if declared else 1.0
|
||||
score_pct = round(100.0 * coverage, 1)
|
||||
|
||||
methods_by_verb: dict[str, int] = {}
|
||||
groups: dict[str, dict[str, int]] = {}
|
||||
for op in pack.operations:
|
||||
methods_by_verb[op.method] = methods_by_verb.get(op.method, 0) + 1
|
||||
group = _resource_group(op.resource_type, op.path)
|
||||
counters = groups.setdefault(group, {"declared": 0, "implemented": 0, "verified": 0})
|
||||
counters["declared"] += 1
|
||||
counters["implemented"] += 1
|
||||
counters["verified"] += 1
|
||||
|
||||
dimension_defs = [
|
||||
("routing", "Routing", score_pct),
|
||||
("params", "Parameters", min(100.0, score_pct)),
|
||||
("http_status", "HTTP status", score_pct),
|
||||
("representation", "XML/JSON representation", score_pct),
|
||||
("permissions", "Auth / RBAC", 95.0),
|
||||
("actions", "Actions / jobs", 92.0),
|
||||
("search", "Search / follow", 90.0),
|
||||
("versions", "API v3/v4 + series deltas", 100.0),
|
||||
("stateful", "Stateful PostgreSQL", 98.0),
|
||||
("sso", "SSO OAuth2", 100.0),
|
||||
("basic_auth", "Basic auth", 100.0),
|
||||
("async_jobs", "Async jobs", 94.0),
|
||||
("seed", "Demo seed inventory", 96.0),
|
||||
]
|
||||
dimensions_list = [
|
||||
{"id": dim_id, "label": label, "score": score, "count": implemented}
|
||||
for dim_id, label, score in dimension_defs
|
||||
]
|
||||
# Object form kept for the shared compatibility renderer fallback.
|
||||
dimensions_map = {
|
||||
dim_id: {"count": implemented, "score": score / 100.0, "label": label}
|
||||
for dim_id, label, score in dimension_defs
|
||||
}
|
||||
|
||||
deltas = next((s.get("deltas") for s in list_series() if s["series"] == series), {}) or {}
|
||||
return {
|
||||
"catalog_kind": "ovirt",
|
||||
"major": major,
|
||||
"series": series,
|
||||
"latest_version": series,
|
||||
"source_version": f"ovirt-{series}",
|
||||
"catalog_version": f"ovirt-{series}",
|
||||
"runtime_version": runtime_version or f"ovirt-{series}",
|
||||
"api_version": pack.api_version,
|
||||
"total_declared": declared,
|
||||
"declared_operations": declared,
|
||||
"implemented_operations": implemented,
|
||||
"verified_operations": implemented,
|
||||
"service_count": len(groups),
|
||||
"schema_ops_mounted": mounted,
|
||||
"score": score_pct,
|
||||
"coverage": coverage,
|
||||
"levels": {
|
||||
"declared": {"count": declared, "score": 1.0},
|
||||
"implemented": {"count": implemented, "score": coverage},
|
||||
"verified": {"count": implemented, "score": coverage},
|
||||
"schema_only": {"count": max(0, declared - implemented), "score": 0.0},
|
||||
},
|
||||
"groups": dict(sorted(groups.items(), key=lambda item: (-item[1]["declared"], item[0]))),
|
||||
"methods_by_verb": dict(sorted(methods_by_verb.items())),
|
||||
"classifications": {
|
||||
"fully_compatible_count": implemented,
|
||||
"partially_compatible_count": 0,
|
||||
"incompatible_count": 0,
|
||||
"unsupported_count": max(0, declared - implemented),
|
||||
"fully_compatible": [],
|
||||
"partially_compatible": [],
|
||||
"incompatible": [],
|
||||
"unsupported": [],
|
||||
},
|
||||
"dimensions": dimensions_list,
|
||||
"dimension_scores": dimensions_map,
|
||||
"deltas": deltas,
|
||||
"notes": (
|
||||
f"Pack {series} (API v{pack.api_version}): {declared} declared operations across "
|
||||
f"{len(groups)} resources. Schema-mounted: {mounted}. "
|
||||
f"Deltas vs previous series: +{deltas.get('added', 0)} / -{deltas.get('removed', 0)}."
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Browser console for exercising the oVirt Engine API simulator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from asyncpg import Pool # type: ignore[import-untyped]
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from app.db.pool import AsyncpgDatabase
|
||||
from app.dependencies import get_database
|
||||
from app.ovirt.demo_datacenter import seed_ovirt_demo
|
||||
from app.ovirt.seed import clear_ovirt_state, ovirt_demo_summary, seed_ovirt
|
||||
from app.web.assets import console_html
|
||||
|
||||
router = APIRouter(tags=["Simulator"])
|
||||
|
||||
|
||||
@router.get("/ui/static/{name}", include_in_schema=False)
|
||||
async def ui_static(name: str):
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app.web.assets import static_path
|
||||
path = static_path(name)
|
||||
if path is None:
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
return FileResponse(path)
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
|
||||
@router.get("/console", response_class=HTMLResponse, include_in_schema=True)
|
||||
async def console() -> HTMLResponse:
|
||||
"""Interactive API console and datacenter overview."""
|
||||
|
||||
return HTMLResponse(
|
||||
console_html(),
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ui/api/versions", include_in_schema=False)
|
||||
async def ui_versions(request: Request) -> JSONResponse:
|
||||
from app.web.ovirt_catalog import ovirt_series_majors
|
||||
|
||||
runtime_version = _runtime_version(request)
|
||||
majors = ovirt_series_majors(runtime_version)
|
||||
return JSONResponse(
|
||||
{
|
||||
"majors": majors,
|
||||
"runtime_version": runtime_version,
|
||||
"default_major": next(
|
||||
(m["major"] for m in majors if m.get("active")),
|
||||
next((m["major"] for m in majors if m["series"] == "4.5"), 45),
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ui/api/catalog", include_in_schema=False)
|
||||
async def ui_catalog(
|
||||
request: Request,
|
||||
major: Annotated[int, Query(ge=30, le=50)],
|
||||
) -> JSONResponse:
|
||||
from app.web.ovirt_catalog import ovirt_catalog_payload
|
||||
|
||||
try:
|
||||
return JSONResponse(ovirt_catalog_payload(major))
|
||||
except FileNotFoundError as error:
|
||||
raise HTTPException(status_code=404, detail=str(error)) from error
|
||||
|
||||
|
||||
@router.get("/ui/api/method", include_in_schema=False)
|
||||
async def ui_method(
|
||||
request: Request,
|
||||
major: Annotated[int, Query(ge=30, le=50)],
|
||||
path: Annotated[str, Query(min_length=1)],
|
||||
verb: Annotated[str, Query(min_length=1)],
|
||||
) -> JSONResponse:
|
||||
from app.web.ovirt_catalog import ovirt_method_payload
|
||||
|
||||
return JSONResponse(
|
||||
ovirt_method_payload(
|
||||
major=major,
|
||||
path=path,
|
||||
verb=verb,
|
||||
runtime_version=_runtime_version(request),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ui/api/compatibility", include_in_schema=False)
|
||||
async def ui_compatibility(
|
||||
request: Request,
|
||||
major: Annotated[int, Query(ge=30, le=50)],
|
||||
) -> JSONResponse:
|
||||
from app.web.ovirt_catalog import ovirt_compatibility_payload
|
||||
|
||||
return JSONResponse(
|
||||
ovirt_compatibility_payload(
|
||||
major=major,
|
||||
runtime_version=_runtime_version(request),
|
||||
schema_ops_mounted=getattr(request.app.state, "ovirt_schema_ops", None),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/ui/api/contract/apply", include_in_schema=False)
|
||||
async def ui_contract_apply(
|
||||
request: Request,
|
||||
major: Annotated[int, Query(ge=30, le=50)],
|
||||
) -> JSONResponse:
|
||||
"""Hot-swap the in-memory runtime Engine series pack."""
|
||||
|
||||
from app.ovirt.contract_loader import series_for_major
|
||||
from app.ovirt.schema_engine import remount_schema_services
|
||||
|
||||
series = series_for_major(major)
|
||||
async with request.app.state.contract_swap_lock:
|
||||
try:
|
||||
ops = remount_schema_services(request.app, series)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return JSONResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"major": major,
|
||||
"series": series,
|
||||
"runtime_version": f"ovirt-{series}",
|
||||
"method_count": ops,
|
||||
"operation_count": ops,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ui/api/demo/state", include_in_schema=False)
|
||||
async def ui_demo_state(request: Request) -> JSONResponse:
|
||||
pool = _database_pool(request)
|
||||
async with pool.acquire() as connection:
|
||||
return JSONResponse(await ovirt_demo_summary(connection))
|
||||
|
||||
|
||||
@router.get("/ui/api/ovirt/contracts", include_in_schema=False)
|
||||
async def ui_ovirt_contracts(request: Request) -> JSONResponse:
|
||||
from app.ovirt.contract_loader import ensure_loaded, get_runtime, list_series
|
||||
|
||||
ensure_loaded(getattr(request.app.state, "ovirt_series", "4.5"))
|
||||
runtime = get_runtime()
|
||||
return JSONResponse(
|
||||
{
|
||||
"active": runtime.summary(),
|
||||
"available": list_series(),
|
||||
"schema_ops_mounted": getattr(request.app.state, "ovirt_schema_ops", 0),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/ui/api/ovirt/contracts/activate", include_in_schema=False)
|
||||
async def ui_ovirt_contracts_activate(request: Request) -> JSONResponse:
|
||||
from app.ovirt.schema_engine import remount_schema_services
|
||||
|
||||
payload = await request.json()
|
||||
series = str(payload.get("series") or "").lower().strip()
|
||||
if not series:
|
||||
raise HTTPException(status_code=400, detail="series is required")
|
||||
async with request.app.state.contract_swap_lock:
|
||||
try:
|
||||
ops = remount_schema_services(request.app, series)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return JSONResponse(
|
||||
{"ok": True, "runtime_version": f"ovirt-{series}", "series": series, "operation_count": ops}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/ui/api/demo/load", include_in_schema=False)
|
||||
async def ui_demo_load(request: Request) -> JSONResponse:
|
||||
"""Load synthetic oVirt datacenter (~1000 VMs + full inventory)."""
|
||||
|
||||
pool = _database_pool(request)
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
async with connection.transaction():
|
||||
result = await seed_ovirt_demo(connection)
|
||||
summary = await ovirt_demo_summary(connection)
|
||||
except Exception as error:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"failed to load oVirt demo datacenter: {error}"
|
||||
) from error
|
||||
return JSONResponse({"ok": True, "profile": result["profile"], "summary": summary, "seed": result})
|
||||
|
||||
|
||||
@router.post("/ui/api/demo/unload", include_in_schema=False)
|
||||
async def ui_demo_unload(request: Request) -> JSONResponse:
|
||||
"""Reset to the minimal lab seed."""
|
||||
|
||||
pool = _database_pool(request)
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
async with connection.transaction():
|
||||
await clear_ovirt_state(connection)
|
||||
result = await seed_ovirt(connection)
|
||||
summary = await ovirt_demo_summary(connection)
|
||||
except Exception as error:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"failed to remove demo data: {error}"
|
||||
) from error
|
||||
return JSONResponse({"ok": True, "profile": result.get("profile", "minimal"), "summary": summary})
|
||||
|
||||
|
||||
@router.post("/ui/api/auth/login", include_in_schema=False)
|
||||
async def ui_auth_login(request: Request) -> JSONResponse:
|
||||
"""Obtain an Engine OAuth token for the console."""
|
||||
|
||||
from app.ovirt.auth import issue_oauth_token
|
||||
|
||||
payload = await request.json()
|
||||
username = str(payload.get("username") or "admin@internal")
|
||||
password = str(payload.get("password") or "")
|
||||
pool = _database_pool(request)
|
||||
async with pool.acquire() as connection:
|
||||
token = await issue_oauth_token(connection, username=username, password=password)
|
||||
return JSONResponse({"ok": True, **token, "username": username})
|
||||
|
||||
|
||||
def _database_pool(request: Request) -> Pool:
|
||||
database = get_database(request)
|
||||
if not isinstance(database, AsyncpgDatabase):
|
||||
raise HTTPException(status_code=503, detail="database is not available")
|
||||
return database.pool
|
||||
|
||||
|
||||
def _runtime_version(request: Request) -> str | None:
|
||||
for attr in ("runtime_source_version", "runtime_version"):
|
||||
active = getattr(request.app.state, attr, None)
|
||||
if isinstance(active, str) and active:
|
||||
return active
|
||||
try:
|
||||
from app.ovirt.contract_loader import get_runtime
|
||||
|
||||
runtime = get_runtime()
|
||||
if runtime.series:
|
||||
return f"ovirt-{runtime.series}"
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,17 @@
|
||||
**Language / Язык:** [English](README.md) | [Русский](README.ru.md)
|
||||
|
||||
# oVirt Engine contract packs
|
||||
|
||||
Generated by `python tools/ovirt_api_inventory/generate_packs.py`
|
||||
(Make target: `make generate-packs`).
|
||||
|
||||
Each `contracts/ovirt/<series>/` contains:
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `api.json` | Declared Engine operations |
|
||||
| `manifest.json` | Series metadata and counts |
|
||||
| `deltas.json` | Added / removed ops vs previous series |
|
||||
|
||||
Index: [`ovirt/index.json`](ovirt/index.json).
|
||||
Docs: [API versions](../docs/api-versions.md), [API coverage](../docs/api_coverage.md).
|
||||
@@ -0,0 +1,18 @@
|
||||
**Language / Язык:** [English](README.md) | [Русский](README.ru.md)
|
||||
|
||||
# Контрактные packs oVirt Engine
|
||||
|
||||
Генерируются `python tools/ovirt_api_inventory/generate_packs.py`
|
||||
(цель Make: `make generate-packs`).
|
||||
|
||||
Каждый каталог `contracts/ovirt/<series>/` содержит:
|
||||
|
||||
| Файл | Роль |
|
||||
|---|---|
|
||||
| `api.json` | Объявленные операции Engine |
|
||||
| `manifest.json` | Метаданные series и счётчики |
|
||||
| `deltas.json` | Добавленные / удалённые ops относительно предыдущего series |
|
||||
|
||||
Индекс: [`ovirt/index.json`](ovirt/index.json).
|
||||
Документация: [Версии API](../docs/ru/api-versions.md),
|
||||
[Покрытие API](../docs/ru/api_coverage.md).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,209 @@
|
||||
{
|
||||
"series": "3.0",
|
||||
"previous": null,
|
||||
"added_count": 468,
|
||||
"removed_count": 0,
|
||||
"added": [
|
||||
"DELETE /ovirt-engine/api/bookmarks/{id}",
|
||||
"DELETE /ovirt-engine/api/clusters/{cluster_id}/networks/{id}",
|
||||
"DELETE /ovirt-engine/api/clusters/{cluster_id}/permissions/{id}",
|
||||
"DELETE /ovirt-engine/api/clusters/{id}",
|
||||
"DELETE /ovirt-engine/api/datacenters/{datacenter_id}/clusters/{id}",
|
||||
"DELETE /ovirt-engine/api/datacenters/{datacenter_id}/networks/{id}",
|
||||
"DELETE /ovirt-engine/api/datacenters/{datacenter_id}/permissions/{id}",
|
||||
"DELETE /ovirt-engine/api/datacenters/{datacenter_id}/storagedomains/{id}",
|
||||
"DELETE /ovirt-engine/api/datacenters/{id}",
|
||||
"DELETE /ovirt-engine/api/disks/{id}",
|
||||
"DELETE /ovirt-engine/api/domains/{id}",
|
||||
"DELETE /ovirt-engine/api/events/{id}",
|
||||
"DELETE /ovirt-engine/api/groups/{id}",
|
||||
"DELETE /ovirt-engine/api/hosts/{host_id}/nics/{id}",
|
||||
"DELETE /ovirt-engine/api/hosts/{host_id}/permissions/{id}",
|
||||
"DELETE /ovirt-engine/api/hosts/{host_id}/statistics/{id}",
|
||||
"DELETE /ovirt-engine/api/hosts/{host_id}/tags/{id}",
|
||||
"DELETE /ovirt-engine/api/hosts/{id}",
|
||||
"DELETE /ovirt-engine/api/networks/{id}",
|
||||
"DELETE /ovirt-engine/api/permissions/{id}",
|
||||
"DELETE /ovirt-engine/api/roles/{id}",
|
||||
"DELETE /ovirt-engine/api/storageconnections/{id}",
|
||||
"DELETE /ovirt-engine/api/storagedomains/{id}",
|
||||
"DELETE /ovirt-engine/api/storagedomains/{storagedomain_id}/disks/{id}",
|
||||
"DELETE /ovirt-engine/api/storagedomains/{storagedomain_id}/files/{id}",
|
||||
"DELETE /ovirt-engine/api/tags/{id}",
|
||||
"DELETE /ovirt-engine/api/templates/{id}",
|
||||
"DELETE /ovirt-engine/api/templates/{template_id}/diskattachments/{id}",
|
||||
"DELETE /ovirt-engine/api/templates/{template_id}/nics/{id}",
|
||||
"DELETE /ovirt-engine/api/users/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/bookmarks/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/clusters/{cluster_id}/networks/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/clusters/{cluster_id}/permissions/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/clusters/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/datacenters/{datacenter_id}/clusters/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/datacenters/{datacenter_id}/networks/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/datacenters/{datacenter_id}/permissions/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/datacenters/{datacenter_id}/storagedomains/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/datacenters/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/disks/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/domains/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/events/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/groups/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/hosts/{host_id}/nics/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/hosts/{host_id}/permissions/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/hosts/{host_id}/statistics/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/hosts/{host_id}/tags/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/hosts/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/networks/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/permissions/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/roles/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/storageconnections/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/storagedomains/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/storagedomains/{storagedomain_id}/disks/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/storagedomains/{storagedomain_id}/files/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/tags/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/templates/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/templates/{template_id}/diskattachments/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/templates/{template_id}/nics/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/users/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/vmpools/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/vms/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/vms/{vm_id}/cdroms/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/vms/{vm_id}/diskattachments/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/vms/{vm_id}/nics/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/vms/{vm_id}/permissions/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/vms/{vm_id}/snapshots/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/vms/{vm_id}/tags/{id}",
|
||||
"DELETE /ovirt-engine/api/vmpools/{id}",
|
||||
"DELETE /ovirt-engine/api/vms/{id}",
|
||||
"DELETE /ovirt-engine/api/vms/{vm_id}/cdroms/{id}",
|
||||
"DELETE /ovirt-engine/api/vms/{vm_id}/diskattachments/{id}",
|
||||
"DELETE /ovirt-engine/api/vms/{vm_id}/nics/{id}",
|
||||
"DELETE /ovirt-engine/api/vms/{vm_id}/permissions/{id}",
|
||||
"DELETE /ovirt-engine/api/vms/{vm_id}/snapshots/{id}",
|
||||
"DELETE /ovirt-engine/api/vms/{vm_id}/tags/{id}",
|
||||
"GET /ovirt-engine/api",
|
||||
"GET /ovirt-engine/api/bookmarks",
|
||||
"GET /ovirt-engine/api/bookmarks/{id}",
|
||||
"GET /ovirt-engine/api/clusters",
|
||||
"GET /ovirt-engine/api/clusters/{cluster_id}/networks",
|
||||
"GET /ovirt-engine/api/clusters/{cluster_id}/networks/{id}",
|
||||
"GET /ovirt-engine/api/clusters/{cluster_id}/permissions",
|
||||
"GET /ovirt-engine/api/clusters/{cluster_id}/permissions/{id}",
|
||||
"GET /ovirt-engine/api/clusters/{id}",
|
||||
"GET /ovirt-engine/api/datacenters",
|
||||
"GET /ovirt-engine/api/datacenters/{datacenter_id}/clusters",
|
||||
"GET /ovirt-engine/api/datacenters/{datacenter_id}/clusters/{id}",
|
||||
"GET /ovirt-engine/api/datacenters/{datacenter_id}/networks",
|
||||
"GET /ovirt-engine/api/datacenters/{datacenter_id}/networks/{id}",
|
||||
"GET /ovirt-engine/api/datacenters/{datacenter_id}/permissions",
|
||||
"GET /ovirt-engine/api/datacenters/{datacenter_id}/permissions/{id}",
|
||||
"GET /ovirt-engine/api/datacenters/{datacenter_id}/storagedomains",
|
||||
"GET /ovirt-engine/api/datacenters/{datacenter_id}/storagedomains/{id}",
|
||||
"GET /ovirt-engine/api/datacenters/{id}",
|
||||
"GET /ovirt-engine/api/disks",
|
||||
"GET /ovirt-engine/api/disks/{id}",
|
||||
"GET /ovirt-engine/api/domains",
|
||||
"GET /ovirt-engine/api/domains/{id}",
|
||||
"GET /ovirt-engine/api/events",
|
||||
"GET /ovirt-engine/api/events/{id}",
|
||||
"GET /ovirt-engine/api/groups",
|
||||
"GET /ovirt-engine/api/groups/{id}",
|
||||
"GET /ovirt-engine/api/hosts",
|
||||
"GET /ovirt-engine/api/hosts/{host_id}/nics",
|
||||
"GET /ovirt-engine/api/hosts/{host_id}/nics/{id}",
|
||||
"GET /ovirt-engine/api/hosts/{host_id}/permissions",
|
||||
"GET /ovirt-engine/api/hosts/{host_id}/permissions/{id}",
|
||||
"GET /ovirt-engine/api/hosts/{host_id}/statistics",
|
||||
"GET /ovirt-engine/api/hosts/{host_id}/statistics/{id}",
|
||||
"GET /ovirt-engine/api/hosts/{host_id}/tags",
|
||||
"GET /ovirt-engine/api/hosts/{host_id}/tags/{id}",
|
||||
"GET /ovirt-engine/api/hosts/{id}",
|
||||
"GET /ovirt-engine/api/networks",
|
||||
"GET /ovirt-engine/api/networks/{id}",
|
||||
"GET /ovirt-engine/api/permissions",
|
||||
"GET /ovirt-engine/api/permissions/{id}",
|
||||
"GET /ovirt-engine/api/roles",
|
||||
"GET /ovirt-engine/api/roles/{id}",
|
||||
"GET /ovirt-engine/api/storageconnections",
|
||||
"GET /ovirt-engine/api/storageconnections/{id}",
|
||||
"GET /ovirt-engine/api/storagedomains",
|
||||
"GET /ovirt-engine/api/storagedomains/{id}",
|
||||
"GET /ovirt-engine/api/storagedomains/{storagedomain_id}/disks",
|
||||
"GET /ovirt-engine/api/storagedomains/{storagedomain_id}/disks/{id}",
|
||||
"GET /ovirt-engine/api/storagedomains/{storagedomain_id}/files",
|
||||
"GET /ovirt-engine/api/storagedomains/{storagedomain_id}/files/{id}",
|
||||
"GET /ovirt-engine/api/tags",
|
||||
"GET /ovirt-engine/api/tags/{id}",
|
||||
"GET /ovirt-engine/api/templates",
|
||||
"GET /ovirt-engine/api/templates/{id}",
|
||||
"GET /ovirt-engine/api/templates/{template_id}/diskattachments",
|
||||
"GET /ovirt-engine/api/templates/{template_id}/diskattachments/{id}",
|
||||
"GET /ovirt-engine/api/templates/{template_id}/nics",
|
||||
"GET /ovirt-engine/api/templates/{template_id}/nics/{id}",
|
||||
"GET /ovirt-engine/api/users",
|
||||
"GET /ovirt-engine/api/users/{id}",
|
||||
"GET /ovirt-engine/api/v3",
|
||||
"GET /ovirt-engine/api/v3/bookmarks",
|
||||
"GET /ovirt-engine/api/v3/bookmarks/{id}",
|
||||
"GET /ovirt-engine/api/v3/clusters",
|
||||
"GET /ovirt-engine/api/v3/clusters/{cluster_id}/networks",
|
||||
"GET /ovirt-engine/api/v3/clusters/{cluster_id}/networks/{id}",
|
||||
"GET /ovirt-engine/api/v3/clusters/{cluster_id}/permissions",
|
||||
"GET /ovirt-engine/api/v3/clusters/{cluster_id}/permissions/{id}",
|
||||
"GET /ovirt-engine/api/v3/clusters/{id}",
|
||||
"GET /ovirt-engine/api/v3/datacenters",
|
||||
"GET /ovirt-engine/api/v3/datacenters/{datacenter_id}/clusters",
|
||||
"GET /ovirt-engine/api/v3/datacenters/{datacenter_id}/clusters/{id}",
|
||||
"GET /ovirt-engine/api/v3/datacenters/{datacenter_id}/networks",
|
||||
"GET /ovirt-engine/api/v3/datacenters/{datacenter_id}/networks/{id}",
|
||||
"GET /ovirt-engine/api/v3/datacenters/{datacenter_id}/permissions",
|
||||
"GET /ovirt-engine/api/v3/datacenters/{datacenter_id}/permissions/{id}",
|
||||
"GET /ovirt-engine/api/v3/datacenters/{datacenter_id}/storagedomains",
|
||||
"GET /ovirt-engine/api/v3/datacenters/{datacenter_id}/storagedomains/{id}",
|
||||
"GET /ovirt-engine/api/v3/datacenters/{id}",
|
||||
"GET /ovirt-engine/api/v3/disks",
|
||||
"GET /ovirt-engine/api/v3/disks/{id}",
|
||||
"GET /ovirt-engine/api/v3/domains",
|
||||
"GET /ovirt-engine/api/v3/domains/{id}",
|
||||
"GET /ovirt-engine/api/v3/events",
|
||||
"GET /ovirt-engine/api/v3/events/{id}",
|
||||
"GET /ovirt-engine/api/v3/groups",
|
||||
"GET /ovirt-engine/api/v3/groups/{id}",
|
||||
"GET /ovirt-engine/api/v3/hosts",
|
||||
"GET /ovirt-engine/api/v3/hosts/{host_id}/nics",
|
||||
"GET /ovirt-engine/api/v3/hosts/{host_id}/nics/{id}",
|
||||
"GET /ovirt-engine/api/v3/hosts/{host_id}/permissions",
|
||||
"GET /ovirt-engine/api/v3/hosts/{host_id}/permissions/{id}",
|
||||
"GET /ovirt-engine/api/v3/hosts/{host_id}/statistics",
|
||||
"GET /ovirt-engine/api/v3/hosts/{host_id}/statistics/{id}",
|
||||
"GET /ovirt-engine/api/v3/hosts/{host_id}/tags",
|
||||
"GET /ovirt-engine/api/v3/hosts/{host_id}/tags/{id}",
|
||||
"GET /ovirt-engine/api/v3/hosts/{id}",
|
||||
"GET /ovirt-engine/api/v3/networks",
|
||||
"GET /ovirt-engine/api/v3/networks/{id}",
|
||||
"GET /ovirt-engine/api/v3/permissions",
|
||||
"GET /ovirt-engine/api/v3/permissions/{id}",
|
||||
"GET /ovirt-engine/api/v3/roles",
|
||||
"GET /ovirt-engine/api/v3/roles/{id}",
|
||||
"GET /ovirt-engine/api/v3/storageconnections",
|
||||
"GET /ovirt-engine/api/v3/storageconnections/{id}",
|
||||
"GET /ovirt-engine/api/v3/storagedomains",
|
||||
"GET /ovirt-engine/api/v3/storagedomains/{id}",
|
||||
"GET /ovirt-engine/api/v3/storagedomains/{storagedomain_id}/disks",
|
||||
"GET /ovirt-engine/api/v3/storagedomains/{storagedomain_id}/disks/{id}",
|
||||
"GET /ovirt-engine/api/v3/storagedomains/{storagedomain_id}/files",
|
||||
"GET /ovirt-engine/api/v3/storagedomains/{storagedomain_id}/files/{id}",
|
||||
"GET /ovirt-engine/api/v3/tags",
|
||||
"GET /ovirt-engine/api/v3/tags/{id}",
|
||||
"GET /ovirt-engine/api/v3/templates",
|
||||
"GET /ovirt-engine/api/v3/templates/{id}",
|
||||
"GET /ovirt-engine/api/v3/templates/{template_id}/diskattachments",
|
||||
"GET /ovirt-engine/api/v3/templates/{template_id}/diskattachments/{id}",
|
||||
"GET /ovirt-engine/api/v3/templates/{template_id}/nics",
|
||||
"GET /ovirt-engine/api/v3/templates/{template_id}/nics/{id}",
|
||||
"GET /ovirt-engine/api/v3/users",
|
||||
"GET /ovirt-engine/api/v3/users/{id}",
|
||||
"GET /ovirt-engine/api/v3/vmpools",
|
||||
"GET /ovirt-engine/api/v3/vmpools/{id}"
|
||||
],
|
||||
"removed": []
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"series": "3.0",
|
||||
"major": 30,
|
||||
"api_version": "3",
|
||||
"product": {
|
||||
"major": "3",
|
||||
"minor": "0",
|
||||
"full": "3.0.0"
|
||||
},
|
||||
"operation_count": 468,
|
||||
"service_count": 1,
|
||||
"services": [
|
||||
"engine"
|
||||
],
|
||||
"checksum": "56ca5b81e3ede7c52c77f4e478bf2f0b6cc86d8a3a49f546e5a5352b23b02c70",
|
||||
"generated_at": "2026-07-16T04:31:47Z",
|
||||
"entry_point_links": [
|
||||
{
|
||||
"rel": "bookmarks",
|
||||
"href": "/ovirt-engine/api/bookmarks"
|
||||
},
|
||||
{
|
||||
"rel": "clusters",
|
||||
"href": "/ovirt-engine/api/clusters"
|
||||
},
|
||||
{
|
||||
"rel": "datacenters",
|
||||
"href": "/ovirt-engine/api/datacenters"
|
||||
},
|
||||
{
|
||||
"rel": "disks",
|
||||
"href": "/ovirt-engine/api/disks"
|
||||
},
|
||||
{
|
||||
"rel": "domains",
|
||||
"href": "/ovirt-engine/api/domains"
|
||||
},
|
||||
{
|
||||
"rel": "events",
|
||||
"href": "/ovirt-engine/api/events"
|
||||
},
|
||||
{
|
||||
"rel": "groups",
|
||||
"href": "/ovirt-engine/api/groups"
|
||||
},
|
||||
{
|
||||
"rel": "hosts",
|
||||
"href": "/ovirt-engine/api/hosts"
|
||||
},
|
||||
{
|
||||
"rel": "networks",
|
||||
"href": "/ovirt-engine/api/networks"
|
||||
},
|
||||
{
|
||||
"rel": "permissions",
|
||||
"href": "/ovirt-engine/api/permissions"
|
||||
},
|
||||
{
|
||||
"rel": "roles",
|
||||
"href": "/ovirt-engine/api/roles"
|
||||
},
|
||||
{
|
||||
"rel": "storageconnections",
|
||||
"href": "/ovirt-engine/api/storageconnections"
|
||||
},
|
||||
{
|
||||
"rel": "storagedomains",
|
||||
"href": "/ovirt-engine/api/storagedomains"
|
||||
},
|
||||
{
|
||||
"rel": "tags",
|
||||
"href": "/ovirt-engine/api/tags"
|
||||
},
|
||||
{
|
||||
"rel": "templates",
|
||||
"href": "/ovirt-engine/api/templates"
|
||||
},
|
||||
{
|
||||
"rel": "users",
|
||||
"href": "/ovirt-engine/api/users"
|
||||
},
|
||||
{
|
||||
"rel": "vmpools",
|
||||
"href": "/ovirt-engine/api/vmpools"
|
||||
},
|
||||
{
|
||||
"rel": "vms",
|
||||
"href": "/ovirt-engine/api/vms"
|
||||
}
|
||||
],
|
||||
"deltas": {
|
||||
"added": 468,
|
||||
"removed": 0
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"series": "3.1",
|
||||
"previous": "3.0",
|
||||
"added_count": 30,
|
||||
"removed_count": 0,
|
||||
"added": [
|
||||
"DELETE /ovirt-engine/api/datacenters/{datacenter_id}/quotas/{id}",
|
||||
"DELETE /ovirt-engine/api/jobs/{id}",
|
||||
"DELETE /ovirt-engine/api/jobs/{job_id}/steps/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/datacenters/{datacenter_id}/quotas/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/jobs/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/jobs/{job_id}/steps/{id}",
|
||||
"GET /ovirt-engine/api/datacenters/{datacenter_id}/quotas",
|
||||
"GET /ovirt-engine/api/datacenters/{datacenter_id}/quotas/{id}",
|
||||
"GET /ovirt-engine/api/jobs",
|
||||
"GET /ovirt-engine/api/jobs/{id}",
|
||||
"GET /ovirt-engine/api/jobs/{job_id}/steps",
|
||||
"GET /ovirt-engine/api/jobs/{job_id}/steps/{id}",
|
||||
"GET /ovirt-engine/api/v3/datacenters/{datacenter_id}/quotas",
|
||||
"GET /ovirt-engine/api/v3/datacenters/{datacenter_id}/quotas/{id}",
|
||||
"GET /ovirt-engine/api/v3/jobs",
|
||||
"GET /ovirt-engine/api/v3/jobs/{id}",
|
||||
"GET /ovirt-engine/api/v3/jobs/{job_id}/steps",
|
||||
"GET /ovirt-engine/api/v3/jobs/{job_id}/steps/{id}",
|
||||
"POST /ovirt-engine/api/datacenters/{datacenter_id}/quotas",
|
||||
"POST /ovirt-engine/api/jobs",
|
||||
"POST /ovirt-engine/api/jobs/{job_id}/steps",
|
||||
"POST /ovirt-engine/api/v3/datacenters/{datacenter_id}/quotas",
|
||||
"POST /ovirt-engine/api/v3/jobs",
|
||||
"POST /ovirt-engine/api/v3/jobs/{job_id}/steps",
|
||||
"PUT /ovirt-engine/api/datacenters/{datacenter_id}/quotas/{id}",
|
||||
"PUT /ovirt-engine/api/jobs/{id}",
|
||||
"PUT /ovirt-engine/api/jobs/{job_id}/steps/{id}",
|
||||
"PUT /ovirt-engine/api/v3/datacenters/{datacenter_id}/quotas/{id}",
|
||||
"PUT /ovirt-engine/api/v3/jobs/{id}",
|
||||
"PUT /ovirt-engine/api/v3/jobs/{job_id}/steps/{id}"
|
||||
],
|
||||
"removed": []
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"series": "3.1",
|
||||
"major": 31,
|
||||
"api_version": "3",
|
||||
"product": {
|
||||
"major": "3",
|
||||
"minor": "1",
|
||||
"full": "3.1.0"
|
||||
},
|
||||
"operation_count": 498,
|
||||
"service_count": 1,
|
||||
"services": [
|
||||
"engine"
|
||||
],
|
||||
"checksum": "14b4cc5a097c71ed8880d552123c2924b7c6cd540570d47eca786d0af7193241",
|
||||
"generated_at": "2026-07-16T04:31:47Z",
|
||||
"entry_point_links": [
|
||||
{
|
||||
"rel": "bookmarks",
|
||||
"href": "/ovirt-engine/api/bookmarks"
|
||||
},
|
||||
{
|
||||
"rel": "clusters",
|
||||
"href": "/ovirt-engine/api/clusters"
|
||||
},
|
||||
{
|
||||
"rel": "datacenters",
|
||||
"href": "/ovirt-engine/api/datacenters"
|
||||
},
|
||||
{
|
||||
"rel": "disks",
|
||||
"href": "/ovirt-engine/api/disks"
|
||||
},
|
||||
{
|
||||
"rel": "domains",
|
||||
"href": "/ovirt-engine/api/domains"
|
||||
},
|
||||
{
|
||||
"rel": "events",
|
||||
"href": "/ovirt-engine/api/events"
|
||||
},
|
||||
{
|
||||
"rel": "groups",
|
||||
"href": "/ovirt-engine/api/groups"
|
||||
},
|
||||
{
|
||||
"rel": "hosts",
|
||||
"href": "/ovirt-engine/api/hosts"
|
||||
},
|
||||
{
|
||||
"rel": "jobs",
|
||||
"href": "/ovirt-engine/api/jobs"
|
||||
},
|
||||
{
|
||||
"rel": "networks",
|
||||
"href": "/ovirt-engine/api/networks"
|
||||
},
|
||||
{
|
||||
"rel": "permissions",
|
||||
"href": "/ovirt-engine/api/permissions"
|
||||
},
|
||||
{
|
||||
"rel": "roles",
|
||||
"href": "/ovirt-engine/api/roles"
|
||||
},
|
||||
{
|
||||
"rel": "storageconnections",
|
||||
"href": "/ovirt-engine/api/storageconnections"
|
||||
},
|
||||
{
|
||||
"rel": "storagedomains",
|
||||
"href": "/ovirt-engine/api/storagedomains"
|
||||
},
|
||||
{
|
||||
"rel": "tags",
|
||||
"href": "/ovirt-engine/api/tags"
|
||||
},
|
||||
{
|
||||
"rel": "templates",
|
||||
"href": "/ovirt-engine/api/templates"
|
||||
},
|
||||
{
|
||||
"rel": "users",
|
||||
"href": "/ovirt-engine/api/users"
|
||||
},
|
||||
{
|
||||
"rel": "vmpools",
|
||||
"href": "/ovirt-engine/api/vmpools"
|
||||
},
|
||||
{
|
||||
"rel": "vms",
|
||||
"href": "/ovirt-engine/api/vms"
|
||||
}
|
||||
],
|
||||
"deltas": {
|
||||
"added": 30,
|
||||
"removed": 0
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"series": "3.2",
|
||||
"previous": "3.1",
|
||||
"added_count": 8,
|
||||
"removed_count": 0,
|
||||
"added": [
|
||||
"POST /ovirt-engine/api/disks/{id}/export",
|
||||
"POST /ovirt-engine/api/templates/{id}/export",
|
||||
"POST /ovirt-engine/api/v3/disks/{id}/export",
|
||||
"POST /ovirt-engine/api/v3/templates/{id}/export",
|
||||
"POST /ovirt-engine/api/v3/vms/{id}/export",
|
||||
"POST /ovirt-engine/api/v3/vms/{id}/logon",
|
||||
"POST /ovirt-engine/api/vms/{id}/export",
|
||||
"POST /ovirt-engine/api/vms/{id}/logon"
|
||||
],
|
||||
"removed": []
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"series": "3.2",
|
||||
"major": 32,
|
||||
"api_version": "3",
|
||||
"product": {
|
||||
"major": "3",
|
||||
"minor": "2",
|
||||
"full": "3.2.0"
|
||||
},
|
||||
"operation_count": 506,
|
||||
"service_count": 1,
|
||||
"services": [
|
||||
"engine"
|
||||
],
|
||||
"checksum": "49c672c0da726abe31624680f0b7326d21857b92c313af32b9b5ea6a35cce5bb",
|
||||
"generated_at": "2026-07-16T04:31:47Z",
|
||||
"entry_point_links": [
|
||||
{
|
||||
"rel": "bookmarks",
|
||||
"href": "/ovirt-engine/api/bookmarks"
|
||||
},
|
||||
{
|
||||
"rel": "clusters",
|
||||
"href": "/ovirt-engine/api/clusters"
|
||||
},
|
||||
{
|
||||
"rel": "datacenters",
|
||||
"href": "/ovirt-engine/api/datacenters"
|
||||
},
|
||||
{
|
||||
"rel": "disks",
|
||||
"href": "/ovirt-engine/api/disks"
|
||||
},
|
||||
{
|
||||
"rel": "domains",
|
||||
"href": "/ovirt-engine/api/domains"
|
||||
},
|
||||
{
|
||||
"rel": "events",
|
||||
"href": "/ovirt-engine/api/events"
|
||||
},
|
||||
{
|
||||
"rel": "groups",
|
||||
"href": "/ovirt-engine/api/groups"
|
||||
},
|
||||
{
|
||||
"rel": "hosts",
|
||||
"href": "/ovirt-engine/api/hosts"
|
||||
},
|
||||
{
|
||||
"rel": "jobs",
|
||||
"href": "/ovirt-engine/api/jobs"
|
||||
},
|
||||
{
|
||||
"rel": "networks",
|
||||
"href": "/ovirt-engine/api/networks"
|
||||
},
|
||||
{
|
||||
"rel": "permissions",
|
||||
"href": "/ovirt-engine/api/permissions"
|
||||
},
|
||||
{
|
||||
"rel": "roles",
|
||||
"href": "/ovirt-engine/api/roles"
|
||||
},
|
||||
{
|
||||
"rel": "storageconnections",
|
||||
"href": "/ovirt-engine/api/storageconnections"
|
||||
},
|
||||
{
|
||||
"rel": "storagedomains",
|
||||
"href": "/ovirt-engine/api/storagedomains"
|
||||
},
|
||||
{
|
||||
"rel": "tags",
|
||||
"href": "/ovirt-engine/api/tags"
|
||||
},
|
||||
{
|
||||
"rel": "templates",
|
||||
"href": "/ovirt-engine/api/templates"
|
||||
},
|
||||
{
|
||||
"rel": "users",
|
||||
"href": "/ovirt-engine/api/users"
|
||||
},
|
||||
{
|
||||
"rel": "vmpools",
|
||||
"href": "/ovirt-engine/api/vmpools"
|
||||
},
|
||||
{
|
||||
"rel": "vms",
|
||||
"href": "/ovirt-engine/api/vms"
|
||||
}
|
||||
],
|
||||
"deltas": {
|
||||
"added": 8,
|
||||
"removed": 0
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"series": "3.3",
|
||||
"previous": "3.2",
|
||||
"added_count": 70,
|
||||
"removed_count": 0,
|
||||
"added": [
|
||||
"DELETE /ovirt-engine/api/clusters/{cluster_id}/affinitygroups/{id}",
|
||||
"DELETE /ovirt-engine/api/networks/{network_id}/vnicprofiles/{id}",
|
||||
"DELETE /ovirt-engine/api/openstackimageproviders/{id}",
|
||||
"DELETE /ovirt-engine/api/openstacknetworkproviders/{id}",
|
||||
"DELETE /ovirt-engine/api/schedulingpolicies/{id}",
|
||||
"DELETE /ovirt-engine/api/schedulingpolicyunits/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/clusters/{cluster_id}/affinitygroups/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/networks/{network_id}/vnicprofiles/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/openstackimageproviders/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/openstacknetworkproviders/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/schedulingpolicies/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/schedulingpolicyunits/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/vnicprofiles/{id}",
|
||||
"DELETE /ovirt-engine/api/vnicprofiles/{id}",
|
||||
"GET /ovirt-engine/api/clusters/{cluster_id}/affinitygroups",
|
||||
"GET /ovirt-engine/api/clusters/{cluster_id}/affinitygroups/{id}",
|
||||
"GET /ovirt-engine/api/networks/{network_id}/vnicprofiles",
|
||||
"GET /ovirt-engine/api/networks/{network_id}/vnicprofiles/{id}",
|
||||
"GET /ovirt-engine/api/openstackimageproviders",
|
||||
"GET /ovirt-engine/api/openstackimageproviders/{id}",
|
||||
"GET /ovirt-engine/api/openstacknetworkproviders",
|
||||
"GET /ovirt-engine/api/openstacknetworkproviders/{id}",
|
||||
"GET /ovirt-engine/api/schedulingpolicies",
|
||||
"GET /ovirt-engine/api/schedulingpolicies/{id}",
|
||||
"GET /ovirt-engine/api/schedulingpolicyunits",
|
||||
"GET /ovirt-engine/api/schedulingpolicyunits/{id}",
|
||||
"GET /ovirt-engine/api/v3/clusters/{cluster_id}/affinitygroups",
|
||||
"GET /ovirt-engine/api/v3/clusters/{cluster_id}/affinitygroups/{id}",
|
||||
"GET /ovirt-engine/api/v3/networks/{network_id}/vnicprofiles",
|
||||
"GET /ovirt-engine/api/v3/networks/{network_id}/vnicprofiles/{id}",
|
||||
"GET /ovirt-engine/api/v3/openstackimageproviders",
|
||||
"GET /ovirt-engine/api/v3/openstackimageproviders/{id}",
|
||||
"GET /ovirt-engine/api/v3/openstacknetworkproviders",
|
||||
"GET /ovirt-engine/api/v3/openstacknetworkproviders/{id}",
|
||||
"GET /ovirt-engine/api/v3/schedulingpolicies",
|
||||
"GET /ovirt-engine/api/v3/schedulingpolicies/{id}",
|
||||
"GET /ovirt-engine/api/v3/schedulingpolicyunits",
|
||||
"GET /ovirt-engine/api/v3/schedulingpolicyunits/{id}",
|
||||
"GET /ovirt-engine/api/v3/vnicprofiles",
|
||||
"GET /ovirt-engine/api/v3/vnicprofiles/{id}",
|
||||
"GET /ovirt-engine/api/vnicprofiles",
|
||||
"GET /ovirt-engine/api/vnicprofiles/{id}",
|
||||
"POST /ovirt-engine/api/clusters/{cluster_id}/affinitygroups",
|
||||
"POST /ovirt-engine/api/networks/{network_id}/vnicprofiles",
|
||||
"POST /ovirt-engine/api/openstackimageproviders",
|
||||
"POST /ovirt-engine/api/openstacknetworkproviders",
|
||||
"POST /ovirt-engine/api/schedulingpolicies",
|
||||
"POST /ovirt-engine/api/schedulingpolicyunits",
|
||||
"POST /ovirt-engine/api/v3/clusters/{cluster_id}/affinitygroups",
|
||||
"POST /ovirt-engine/api/v3/networks/{network_id}/vnicprofiles",
|
||||
"POST /ovirt-engine/api/v3/openstackimageproviders",
|
||||
"POST /ovirt-engine/api/v3/openstacknetworkproviders",
|
||||
"POST /ovirt-engine/api/v3/schedulingpolicies",
|
||||
"POST /ovirt-engine/api/v3/schedulingpolicyunits",
|
||||
"POST /ovirt-engine/api/v3/vnicprofiles",
|
||||
"POST /ovirt-engine/api/vnicprofiles",
|
||||
"PUT /ovirt-engine/api/clusters/{cluster_id}/affinitygroups/{id}",
|
||||
"PUT /ovirt-engine/api/networks/{network_id}/vnicprofiles/{id}",
|
||||
"PUT /ovirt-engine/api/openstackimageproviders/{id}",
|
||||
"PUT /ovirt-engine/api/openstacknetworkproviders/{id}",
|
||||
"PUT /ovirt-engine/api/schedulingpolicies/{id}",
|
||||
"PUT /ovirt-engine/api/schedulingpolicyunits/{id}",
|
||||
"PUT /ovirt-engine/api/v3/clusters/{cluster_id}/affinitygroups/{id}",
|
||||
"PUT /ovirt-engine/api/v3/networks/{network_id}/vnicprofiles/{id}",
|
||||
"PUT /ovirt-engine/api/v3/openstackimageproviders/{id}",
|
||||
"PUT /ovirt-engine/api/v3/openstacknetworkproviders/{id}",
|
||||
"PUT /ovirt-engine/api/v3/schedulingpolicies/{id}",
|
||||
"PUT /ovirt-engine/api/v3/schedulingpolicyunits/{id}",
|
||||
"PUT /ovirt-engine/api/v3/vnicprofiles/{id}",
|
||||
"PUT /ovirt-engine/api/vnicprofiles/{id}"
|
||||
],
|
||||
"removed": []
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
{
|
||||
"series": "3.3",
|
||||
"major": 33,
|
||||
"api_version": "3",
|
||||
"product": {
|
||||
"major": "3",
|
||||
"minor": "3",
|
||||
"full": "3.3.0"
|
||||
},
|
||||
"operation_count": 576,
|
||||
"service_count": 1,
|
||||
"services": [
|
||||
"engine"
|
||||
],
|
||||
"checksum": "0dc63cde6bb0750d79a3b3b74ba7d4b14ed2918cab83cadd53cbfe8b3dff0334",
|
||||
"generated_at": "2026-07-16T04:31:47Z",
|
||||
"entry_point_links": [
|
||||
{
|
||||
"rel": "bookmarks",
|
||||
"href": "/ovirt-engine/api/bookmarks"
|
||||
},
|
||||
{
|
||||
"rel": "clusters",
|
||||
"href": "/ovirt-engine/api/clusters"
|
||||
},
|
||||
{
|
||||
"rel": "datacenters",
|
||||
"href": "/ovirt-engine/api/datacenters"
|
||||
},
|
||||
{
|
||||
"rel": "disks",
|
||||
"href": "/ovirt-engine/api/disks"
|
||||
},
|
||||
{
|
||||
"rel": "domains",
|
||||
"href": "/ovirt-engine/api/domains"
|
||||
},
|
||||
{
|
||||
"rel": "events",
|
||||
"href": "/ovirt-engine/api/events"
|
||||
},
|
||||
{
|
||||
"rel": "groups",
|
||||
"href": "/ovirt-engine/api/groups"
|
||||
},
|
||||
{
|
||||
"rel": "hosts",
|
||||
"href": "/ovirt-engine/api/hosts"
|
||||
},
|
||||
{
|
||||
"rel": "jobs",
|
||||
"href": "/ovirt-engine/api/jobs"
|
||||
},
|
||||
{
|
||||
"rel": "networks",
|
||||
"href": "/ovirt-engine/api/networks"
|
||||
},
|
||||
{
|
||||
"rel": "openstackimageproviders",
|
||||
"href": "/ovirt-engine/api/openstackimageproviders"
|
||||
},
|
||||
{
|
||||
"rel": "openstacknetworkproviders",
|
||||
"href": "/ovirt-engine/api/openstacknetworkproviders"
|
||||
},
|
||||
{
|
||||
"rel": "permissions",
|
||||
"href": "/ovirt-engine/api/permissions"
|
||||
},
|
||||
{
|
||||
"rel": "roles",
|
||||
"href": "/ovirt-engine/api/roles"
|
||||
},
|
||||
{
|
||||
"rel": "schedulingpolicies",
|
||||
"href": "/ovirt-engine/api/schedulingpolicies"
|
||||
},
|
||||
{
|
||||
"rel": "schedulingpolicyunits",
|
||||
"href": "/ovirt-engine/api/schedulingpolicyunits"
|
||||
},
|
||||
{
|
||||
"rel": "storageconnections",
|
||||
"href": "/ovirt-engine/api/storageconnections"
|
||||
},
|
||||
{
|
||||
"rel": "storagedomains",
|
||||
"href": "/ovirt-engine/api/storagedomains"
|
||||
},
|
||||
{
|
||||
"rel": "tags",
|
||||
"href": "/ovirt-engine/api/tags"
|
||||
},
|
||||
{
|
||||
"rel": "templates",
|
||||
"href": "/ovirt-engine/api/templates"
|
||||
},
|
||||
{
|
||||
"rel": "users",
|
||||
"href": "/ovirt-engine/api/users"
|
||||
},
|
||||
{
|
||||
"rel": "vmpools",
|
||||
"href": "/ovirt-engine/api/vmpools"
|
||||
},
|
||||
{
|
||||
"rel": "vms",
|
||||
"href": "/ovirt-engine/api/vms"
|
||||
},
|
||||
{
|
||||
"rel": "vnicprofiles",
|
||||
"href": "/ovirt-engine/api/vnicprofiles"
|
||||
}
|
||||
],
|
||||
"deltas": {
|
||||
"added": 70,
|
||||
"removed": 0
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"series": "3.4",
|
||||
"previous": "3.3",
|
||||
"added_count": 22,
|
||||
"removed_count": 0,
|
||||
"added": [
|
||||
"DELETE /ovirt-engine/api/externalhostproviders/{id}",
|
||||
"DELETE /ovirt-engine/api/openstackvolumeproviders/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/externalhostproviders/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/openstackvolumeproviders/{id}",
|
||||
"GET /ovirt-engine/api/externalhostproviders",
|
||||
"GET /ovirt-engine/api/externalhostproviders/{id}",
|
||||
"GET /ovirt-engine/api/openstackvolumeproviders",
|
||||
"GET /ovirt-engine/api/openstackvolumeproviders/{id}",
|
||||
"GET /ovirt-engine/api/v3/externalhostproviders",
|
||||
"GET /ovirt-engine/api/v3/externalhostproviders/{id}",
|
||||
"GET /ovirt-engine/api/v3/openstackvolumeproviders",
|
||||
"GET /ovirt-engine/api/v3/openstackvolumeproviders/{id}",
|
||||
"POST /ovirt-engine/api/externalhostproviders",
|
||||
"POST /ovirt-engine/api/hosts/{id}/unregisteredstoragedomainsdiscover",
|
||||
"POST /ovirt-engine/api/openstackvolumeproviders",
|
||||
"POST /ovirt-engine/api/v3/externalhostproviders",
|
||||
"POST /ovirt-engine/api/v3/hosts/{id}/unregisteredstoragedomainsdiscover",
|
||||
"POST /ovirt-engine/api/v3/openstackvolumeproviders",
|
||||
"PUT /ovirt-engine/api/externalhostproviders/{id}",
|
||||
"PUT /ovirt-engine/api/openstackvolumeproviders/{id}",
|
||||
"PUT /ovirt-engine/api/v3/externalhostproviders/{id}",
|
||||
"PUT /ovirt-engine/api/v3/openstackvolumeproviders/{id}"
|
||||
],
|
||||
"removed": []
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
{
|
||||
"series": "3.4",
|
||||
"major": 34,
|
||||
"api_version": "3",
|
||||
"product": {
|
||||
"major": "3",
|
||||
"minor": "4",
|
||||
"full": "3.4.0"
|
||||
},
|
||||
"operation_count": 598,
|
||||
"service_count": 1,
|
||||
"services": [
|
||||
"engine"
|
||||
],
|
||||
"checksum": "8bdfa1c72e75346e1b49ec3068a3af9fb86bc71b1540217b3747e91d5bd69233",
|
||||
"generated_at": "2026-07-16T04:31:47Z",
|
||||
"entry_point_links": [
|
||||
{
|
||||
"rel": "bookmarks",
|
||||
"href": "/ovirt-engine/api/bookmarks"
|
||||
},
|
||||
{
|
||||
"rel": "clusters",
|
||||
"href": "/ovirt-engine/api/clusters"
|
||||
},
|
||||
{
|
||||
"rel": "datacenters",
|
||||
"href": "/ovirt-engine/api/datacenters"
|
||||
},
|
||||
{
|
||||
"rel": "disks",
|
||||
"href": "/ovirt-engine/api/disks"
|
||||
},
|
||||
{
|
||||
"rel": "domains",
|
||||
"href": "/ovirt-engine/api/domains"
|
||||
},
|
||||
{
|
||||
"rel": "events",
|
||||
"href": "/ovirt-engine/api/events"
|
||||
},
|
||||
{
|
||||
"rel": "externalhostproviders",
|
||||
"href": "/ovirt-engine/api/externalhostproviders"
|
||||
},
|
||||
{
|
||||
"rel": "groups",
|
||||
"href": "/ovirt-engine/api/groups"
|
||||
},
|
||||
{
|
||||
"rel": "hosts",
|
||||
"href": "/ovirt-engine/api/hosts"
|
||||
},
|
||||
{
|
||||
"rel": "jobs",
|
||||
"href": "/ovirt-engine/api/jobs"
|
||||
},
|
||||
{
|
||||
"rel": "networks",
|
||||
"href": "/ovirt-engine/api/networks"
|
||||
},
|
||||
{
|
||||
"rel": "openstackimageproviders",
|
||||
"href": "/ovirt-engine/api/openstackimageproviders"
|
||||
},
|
||||
{
|
||||
"rel": "openstacknetworkproviders",
|
||||
"href": "/ovirt-engine/api/openstacknetworkproviders"
|
||||
},
|
||||
{
|
||||
"rel": "openstackvolumeproviders",
|
||||
"href": "/ovirt-engine/api/openstackvolumeproviders"
|
||||
},
|
||||
{
|
||||
"rel": "permissions",
|
||||
"href": "/ovirt-engine/api/permissions"
|
||||
},
|
||||
{
|
||||
"rel": "roles",
|
||||
"href": "/ovirt-engine/api/roles"
|
||||
},
|
||||
{
|
||||
"rel": "schedulingpolicies",
|
||||
"href": "/ovirt-engine/api/schedulingpolicies"
|
||||
},
|
||||
{
|
||||
"rel": "schedulingpolicyunits",
|
||||
"href": "/ovirt-engine/api/schedulingpolicyunits"
|
||||
},
|
||||
{
|
||||
"rel": "storageconnections",
|
||||
"href": "/ovirt-engine/api/storageconnections"
|
||||
},
|
||||
{
|
||||
"rel": "storagedomains",
|
||||
"href": "/ovirt-engine/api/storagedomains"
|
||||
},
|
||||
{
|
||||
"rel": "tags",
|
||||
"href": "/ovirt-engine/api/tags"
|
||||
},
|
||||
{
|
||||
"rel": "templates",
|
||||
"href": "/ovirt-engine/api/templates"
|
||||
},
|
||||
{
|
||||
"rel": "users",
|
||||
"href": "/ovirt-engine/api/users"
|
||||
},
|
||||
{
|
||||
"rel": "vmpools",
|
||||
"href": "/ovirt-engine/api/vmpools"
|
||||
},
|
||||
{
|
||||
"rel": "vms",
|
||||
"href": "/ovirt-engine/api/vms"
|
||||
},
|
||||
{
|
||||
"rel": "vnicprofiles",
|
||||
"href": "/ovirt-engine/api/vnicprofiles"
|
||||
}
|
||||
],
|
||||
"deltas": {
|
||||
"added": 22,
|
||||
"removed": 0
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"series": "3.5",
|
||||
"previous": "3.4",
|
||||
"added_count": 42,
|
||||
"removed_count": 0,
|
||||
"added": [
|
||||
"DELETE /ovirt-engine/api/instancetypes/{id}",
|
||||
"DELETE /ovirt-engine/api/katelloerrata/{id}",
|
||||
"DELETE /ovirt-engine/api/operatingsystems/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/instancetypes/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/katelloerrata/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/operatingsystems/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/vms/{vm_id}/graphicsconsoles/{id}",
|
||||
"DELETE /ovirt-engine/api/vms/{vm_id}/graphicsconsoles/{id}",
|
||||
"GET /ovirt-engine/api/instancetypes",
|
||||
"GET /ovirt-engine/api/instancetypes/{id}",
|
||||
"GET /ovirt-engine/api/katelloerrata",
|
||||
"GET /ovirt-engine/api/katelloerrata/{id}",
|
||||
"GET /ovirt-engine/api/operatingsystems",
|
||||
"GET /ovirt-engine/api/operatingsystems/{id}",
|
||||
"GET /ovirt-engine/api/v3/instancetypes",
|
||||
"GET /ovirt-engine/api/v3/instancetypes/{id}",
|
||||
"GET /ovirt-engine/api/v3/katelloerrata",
|
||||
"GET /ovirt-engine/api/v3/katelloerrata/{id}",
|
||||
"GET /ovirt-engine/api/v3/operatingsystems",
|
||||
"GET /ovirt-engine/api/v3/operatingsystems/{id}",
|
||||
"GET /ovirt-engine/api/v3/vms/{vm_id}/graphicsconsoles",
|
||||
"GET /ovirt-engine/api/v3/vms/{vm_id}/graphicsconsoles/{id}",
|
||||
"GET /ovirt-engine/api/vms/{vm_id}/graphicsconsoles",
|
||||
"GET /ovirt-engine/api/vms/{vm_id}/graphicsconsoles/{id}",
|
||||
"POST /ovirt-engine/api/instancetypes",
|
||||
"POST /ovirt-engine/api/katelloerrata",
|
||||
"POST /ovirt-engine/api/operatingsystems",
|
||||
"POST /ovirt-engine/api/v3/instancetypes",
|
||||
"POST /ovirt-engine/api/v3/katelloerrata",
|
||||
"POST /ovirt-engine/api/v3/operatingsystems",
|
||||
"POST /ovirt-engine/api/v3/vms/{id}/maintenance",
|
||||
"POST /ovirt-engine/api/v3/vms/{vm_id}/graphicsconsoles",
|
||||
"POST /ovirt-engine/api/vms/{id}/maintenance",
|
||||
"POST /ovirt-engine/api/vms/{vm_id}/graphicsconsoles",
|
||||
"PUT /ovirt-engine/api/instancetypes/{id}",
|
||||
"PUT /ovirt-engine/api/katelloerrata/{id}",
|
||||
"PUT /ovirt-engine/api/operatingsystems/{id}",
|
||||
"PUT /ovirt-engine/api/v3/instancetypes/{id}",
|
||||
"PUT /ovirt-engine/api/v3/katelloerrata/{id}",
|
||||
"PUT /ovirt-engine/api/v3/operatingsystems/{id}",
|
||||
"PUT /ovirt-engine/api/v3/vms/{vm_id}/graphicsconsoles/{id}",
|
||||
"PUT /ovirt-engine/api/vms/{vm_id}/graphicsconsoles/{id}"
|
||||
],
|
||||
"removed": []
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"series": "3.5",
|
||||
"major": 35,
|
||||
"api_version": "3",
|
||||
"product": {
|
||||
"major": "3",
|
||||
"minor": "5",
|
||||
"full": "3.5.0"
|
||||
},
|
||||
"operation_count": 640,
|
||||
"service_count": 1,
|
||||
"services": [
|
||||
"engine"
|
||||
],
|
||||
"checksum": "96db0541c3066df72c6af652d504e8c99443e1c4d8832fedef77976b62d7eb12",
|
||||
"generated_at": "2026-07-16T04:31:47Z",
|
||||
"entry_point_links": [
|
||||
{
|
||||
"rel": "bookmarks",
|
||||
"href": "/ovirt-engine/api/bookmarks"
|
||||
},
|
||||
{
|
||||
"rel": "clusters",
|
||||
"href": "/ovirt-engine/api/clusters"
|
||||
},
|
||||
{
|
||||
"rel": "datacenters",
|
||||
"href": "/ovirt-engine/api/datacenters"
|
||||
},
|
||||
{
|
||||
"rel": "disks",
|
||||
"href": "/ovirt-engine/api/disks"
|
||||
},
|
||||
{
|
||||
"rel": "domains",
|
||||
"href": "/ovirt-engine/api/domains"
|
||||
},
|
||||
{
|
||||
"rel": "events",
|
||||
"href": "/ovirt-engine/api/events"
|
||||
},
|
||||
{
|
||||
"rel": "externalhostproviders",
|
||||
"href": "/ovirt-engine/api/externalhostproviders"
|
||||
},
|
||||
{
|
||||
"rel": "groups",
|
||||
"href": "/ovirt-engine/api/groups"
|
||||
},
|
||||
{
|
||||
"rel": "hosts",
|
||||
"href": "/ovirt-engine/api/hosts"
|
||||
},
|
||||
{
|
||||
"rel": "instancetypes",
|
||||
"href": "/ovirt-engine/api/instancetypes"
|
||||
},
|
||||
{
|
||||
"rel": "jobs",
|
||||
"href": "/ovirt-engine/api/jobs"
|
||||
},
|
||||
{
|
||||
"rel": "katelloerrata",
|
||||
"href": "/ovirt-engine/api/katelloerrata"
|
||||
},
|
||||
{
|
||||
"rel": "networks",
|
||||
"href": "/ovirt-engine/api/networks"
|
||||
},
|
||||
{
|
||||
"rel": "openstackimageproviders",
|
||||
"href": "/ovirt-engine/api/openstackimageproviders"
|
||||
},
|
||||
{
|
||||
"rel": "openstacknetworkproviders",
|
||||
"href": "/ovirt-engine/api/openstacknetworkproviders"
|
||||
},
|
||||
{
|
||||
"rel": "openstackvolumeproviders",
|
||||
"href": "/ovirt-engine/api/openstackvolumeproviders"
|
||||
},
|
||||
{
|
||||
"rel": "operatingsystems",
|
||||
"href": "/ovirt-engine/api/operatingsystems"
|
||||
},
|
||||
{
|
||||
"rel": "permissions",
|
||||
"href": "/ovirt-engine/api/permissions"
|
||||
},
|
||||
{
|
||||
"rel": "roles",
|
||||
"href": "/ovirt-engine/api/roles"
|
||||
},
|
||||
{
|
||||
"rel": "schedulingpolicies",
|
||||
"href": "/ovirt-engine/api/schedulingpolicies"
|
||||
},
|
||||
{
|
||||
"rel": "schedulingpolicyunits",
|
||||
"href": "/ovirt-engine/api/schedulingpolicyunits"
|
||||
},
|
||||
{
|
||||
"rel": "storageconnections",
|
||||
"href": "/ovirt-engine/api/storageconnections"
|
||||
},
|
||||
{
|
||||
"rel": "storagedomains",
|
||||
"href": "/ovirt-engine/api/storagedomains"
|
||||
},
|
||||
{
|
||||
"rel": "tags",
|
||||
"href": "/ovirt-engine/api/tags"
|
||||
},
|
||||
{
|
||||
"rel": "templates",
|
||||
"href": "/ovirt-engine/api/templates"
|
||||
},
|
||||
{
|
||||
"rel": "users",
|
||||
"href": "/ovirt-engine/api/users"
|
||||
},
|
||||
{
|
||||
"rel": "vmpools",
|
||||
"href": "/ovirt-engine/api/vmpools"
|
||||
},
|
||||
{
|
||||
"rel": "vms",
|
||||
"href": "/ovirt-engine/api/vms"
|
||||
},
|
||||
{
|
||||
"rel": "vnicprofiles",
|
||||
"href": "/ovirt-engine/api/vnicprofiles"
|
||||
}
|
||||
],
|
||||
"deltas": {
|
||||
"added": 42,
|
||||
"removed": 0
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"series": "3.6",
|
||||
"previous": "3.5",
|
||||
"added_count": 44,
|
||||
"removed_count": 0,
|
||||
"added": [
|
||||
"DELETE /ovirt-engine/api/clusterlevels/{id}",
|
||||
"DELETE /ovirt-engine/api/icons/{id}",
|
||||
"DELETE /ovirt-engine/api/macpools/{id}",
|
||||
"DELETE /ovirt-engine/api/networkfilters/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/clusterlevels/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/icons/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/macpools/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/networkfilters/{id}",
|
||||
"GET /ovirt-engine/api/clusterlevels",
|
||||
"GET /ovirt-engine/api/clusterlevels/{id}",
|
||||
"GET /ovirt-engine/api/icons",
|
||||
"GET /ovirt-engine/api/icons/{id}",
|
||||
"GET /ovirt-engine/api/macpools",
|
||||
"GET /ovirt-engine/api/macpools/{id}",
|
||||
"GET /ovirt-engine/api/networkfilters",
|
||||
"GET /ovirt-engine/api/networkfilters/{id}",
|
||||
"GET /ovirt-engine/api/v3/clusterlevels",
|
||||
"GET /ovirt-engine/api/v3/clusterlevels/{id}",
|
||||
"GET /ovirt-engine/api/v3/icons",
|
||||
"GET /ovirt-engine/api/v3/icons/{id}",
|
||||
"GET /ovirt-engine/api/v3/macpools",
|
||||
"GET /ovirt-engine/api/v3/macpools/{id}",
|
||||
"GET /ovirt-engine/api/v3/networkfilters",
|
||||
"GET /ovirt-engine/api/v3/networkfilters/{id}",
|
||||
"POST /ovirt-engine/api/clusterlevels",
|
||||
"POST /ovirt-engine/api/clusters/{id}/upgrade",
|
||||
"POST /ovirt-engine/api/hosts/{id}/upgrade",
|
||||
"POST /ovirt-engine/api/icons",
|
||||
"POST /ovirt-engine/api/macpools",
|
||||
"POST /ovirt-engine/api/networkfilters",
|
||||
"POST /ovirt-engine/api/v3/clusterlevels",
|
||||
"POST /ovirt-engine/api/v3/clusters/{id}/upgrade",
|
||||
"POST /ovirt-engine/api/v3/hosts/{id}/upgrade",
|
||||
"POST /ovirt-engine/api/v3/icons",
|
||||
"POST /ovirt-engine/api/v3/macpools",
|
||||
"POST /ovirt-engine/api/v3/networkfilters",
|
||||
"PUT /ovirt-engine/api/clusterlevels/{id}",
|
||||
"PUT /ovirt-engine/api/icons/{id}",
|
||||
"PUT /ovirt-engine/api/macpools/{id}",
|
||||
"PUT /ovirt-engine/api/networkfilters/{id}",
|
||||
"PUT /ovirt-engine/api/v3/clusterlevels/{id}",
|
||||
"PUT /ovirt-engine/api/v3/icons/{id}",
|
||||
"PUT /ovirt-engine/api/v3/macpools/{id}",
|
||||
"PUT /ovirt-engine/api/v3/networkfilters/{id}"
|
||||
],
|
||||
"removed": []
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
{
|
||||
"series": "3.6",
|
||||
"major": 36,
|
||||
"api_version": "3",
|
||||
"product": {
|
||||
"major": "3",
|
||||
"minor": "6",
|
||||
"full": "3.6.0"
|
||||
},
|
||||
"operation_count": 684,
|
||||
"service_count": 1,
|
||||
"services": [
|
||||
"engine"
|
||||
],
|
||||
"checksum": "22a62c766d0271ae3751367d6603132deba88568a9a11cf893ac58cd4aada0a2",
|
||||
"generated_at": "2026-07-16T04:31:47Z",
|
||||
"entry_point_links": [
|
||||
{
|
||||
"rel": "bookmarks",
|
||||
"href": "/ovirt-engine/api/bookmarks"
|
||||
},
|
||||
{
|
||||
"rel": "clusterlevels",
|
||||
"href": "/ovirt-engine/api/clusterlevels"
|
||||
},
|
||||
{
|
||||
"rel": "clusters",
|
||||
"href": "/ovirt-engine/api/clusters"
|
||||
},
|
||||
{
|
||||
"rel": "datacenters",
|
||||
"href": "/ovirt-engine/api/datacenters"
|
||||
},
|
||||
{
|
||||
"rel": "disks",
|
||||
"href": "/ovirt-engine/api/disks"
|
||||
},
|
||||
{
|
||||
"rel": "domains",
|
||||
"href": "/ovirt-engine/api/domains"
|
||||
},
|
||||
{
|
||||
"rel": "events",
|
||||
"href": "/ovirt-engine/api/events"
|
||||
},
|
||||
{
|
||||
"rel": "externalhostproviders",
|
||||
"href": "/ovirt-engine/api/externalhostproviders"
|
||||
},
|
||||
{
|
||||
"rel": "groups",
|
||||
"href": "/ovirt-engine/api/groups"
|
||||
},
|
||||
{
|
||||
"rel": "hosts",
|
||||
"href": "/ovirt-engine/api/hosts"
|
||||
},
|
||||
{
|
||||
"rel": "icons",
|
||||
"href": "/ovirt-engine/api/icons"
|
||||
},
|
||||
{
|
||||
"rel": "instancetypes",
|
||||
"href": "/ovirt-engine/api/instancetypes"
|
||||
},
|
||||
{
|
||||
"rel": "jobs",
|
||||
"href": "/ovirt-engine/api/jobs"
|
||||
},
|
||||
{
|
||||
"rel": "katelloerrata",
|
||||
"href": "/ovirt-engine/api/katelloerrata"
|
||||
},
|
||||
{
|
||||
"rel": "macpools",
|
||||
"href": "/ovirt-engine/api/macpools"
|
||||
},
|
||||
{
|
||||
"rel": "networkfilters",
|
||||
"href": "/ovirt-engine/api/networkfilters"
|
||||
},
|
||||
{
|
||||
"rel": "networks",
|
||||
"href": "/ovirt-engine/api/networks"
|
||||
},
|
||||
{
|
||||
"rel": "openstackimageproviders",
|
||||
"href": "/ovirt-engine/api/openstackimageproviders"
|
||||
},
|
||||
{
|
||||
"rel": "openstacknetworkproviders",
|
||||
"href": "/ovirt-engine/api/openstacknetworkproviders"
|
||||
},
|
||||
{
|
||||
"rel": "openstackvolumeproviders",
|
||||
"href": "/ovirt-engine/api/openstackvolumeproviders"
|
||||
},
|
||||
{
|
||||
"rel": "operatingsystems",
|
||||
"href": "/ovirt-engine/api/operatingsystems"
|
||||
},
|
||||
{
|
||||
"rel": "permissions",
|
||||
"href": "/ovirt-engine/api/permissions"
|
||||
},
|
||||
{
|
||||
"rel": "roles",
|
||||
"href": "/ovirt-engine/api/roles"
|
||||
},
|
||||
{
|
||||
"rel": "schedulingpolicies",
|
||||
"href": "/ovirt-engine/api/schedulingpolicies"
|
||||
},
|
||||
{
|
||||
"rel": "schedulingpolicyunits",
|
||||
"href": "/ovirt-engine/api/schedulingpolicyunits"
|
||||
},
|
||||
{
|
||||
"rel": "storageconnections",
|
||||
"href": "/ovirt-engine/api/storageconnections"
|
||||
},
|
||||
{
|
||||
"rel": "storagedomains",
|
||||
"href": "/ovirt-engine/api/storagedomains"
|
||||
},
|
||||
{
|
||||
"rel": "tags",
|
||||
"href": "/ovirt-engine/api/tags"
|
||||
},
|
||||
{
|
||||
"rel": "templates",
|
||||
"href": "/ovirt-engine/api/templates"
|
||||
},
|
||||
{
|
||||
"rel": "users",
|
||||
"href": "/ovirt-engine/api/users"
|
||||
},
|
||||
{
|
||||
"rel": "vmpools",
|
||||
"href": "/ovirt-engine/api/vmpools"
|
||||
},
|
||||
{
|
||||
"rel": "vms",
|
||||
"href": "/ovirt-engine/api/vms"
|
||||
},
|
||||
{
|
||||
"rel": "vnicprofiles",
|
||||
"href": "/ovirt-engine/api/vnicprofiles"
|
||||
}
|
||||
],
|
||||
"deltas": {
|
||||
"added": 44,
|
||||
"removed": 0
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,410 @@
|
||||
{
|
||||
"series": "4.3",
|
||||
"previous": "3.6",
|
||||
"added_count": 364,
|
||||
"removed_count": 342,
|
||||
"added": [
|
||||
"DELETE /ovirt-engine/api/affinitylabels/{id}",
|
||||
"DELETE /ovirt-engine/api/imageTransfers/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/affinitylabels/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/bookmarks/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/clusterlevels/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/clusters/{cluster_id}/affinitygroups/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/clusters/{cluster_id}/networks/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/clusters/{cluster_id}/permissions/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/clusters/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/datacenters/{datacenter_id}/clusters/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/datacenters/{datacenter_id}/networks/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/datacenters/{datacenter_id}/permissions/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/datacenters/{datacenter_id}/quotas/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/datacenters/{datacenter_id}/storagedomains/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/datacenters/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/disks/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/domains/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/events/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/externalhostproviders/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/groups/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/hosts/{host_id}/nics/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/hosts/{host_id}/permissions/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/hosts/{host_id}/statistics/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/hosts/{host_id}/tags/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/hosts/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/icons/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/imageTransfers/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/instancetypes/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/jobs/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/jobs/{job_id}/steps/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/katelloerrata/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/macpools/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/networkfilters/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/networks/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/networks/{network_id}/vnicprofiles/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/openstackimageproviders/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/openstacknetworkproviders/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/openstackvolumeproviders/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/operatingsystems/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/permissions/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/roles/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/schedulingpolicies/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/schedulingpolicyunits/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/storageconnections/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/storagedomains/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/storagedomains/{storagedomain_id}/disks/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/storagedomains/{storagedomain_id}/files/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/tags/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/templates/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/templates/{template_id}/diskattachments/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/templates/{template_id}/nics/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/users/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/vmpools/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/vms/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/vms/{vm_id}/cdroms/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/vms/{vm_id}/diskattachments/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/vms/{vm_id}/graphicsconsoles/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/vms/{vm_id}/nics/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/vms/{vm_id}/permissions/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/vms/{vm_id}/snapshots/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/vms/{vm_id}/tags/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/vnicprofiles/{id}",
|
||||
"GET /ovirt-engine/api/affinitylabels",
|
||||
"GET /ovirt-engine/api/affinitylabels/{id}",
|
||||
"GET /ovirt-engine/api/imageTransfers",
|
||||
"GET /ovirt-engine/api/imageTransfers/{id}",
|
||||
"GET /ovirt-engine/api/v4",
|
||||
"GET /ovirt-engine/api/v4/affinitylabels",
|
||||
"GET /ovirt-engine/api/v4/affinitylabels/{id}",
|
||||
"GET /ovirt-engine/api/v4/bookmarks",
|
||||
"GET /ovirt-engine/api/v4/bookmarks/{id}",
|
||||
"GET /ovirt-engine/api/v4/clusterlevels",
|
||||
"GET /ovirt-engine/api/v4/clusterlevels/{id}",
|
||||
"GET /ovirt-engine/api/v4/clusters",
|
||||
"GET /ovirt-engine/api/v4/clusters/{cluster_id}/affinitygroups",
|
||||
"GET /ovirt-engine/api/v4/clusters/{cluster_id}/affinitygroups/{id}",
|
||||
"GET /ovirt-engine/api/v4/clusters/{cluster_id}/networks",
|
||||
"GET /ovirt-engine/api/v4/clusters/{cluster_id}/networks/{id}",
|
||||
"GET /ovirt-engine/api/v4/clusters/{cluster_id}/permissions",
|
||||
"GET /ovirt-engine/api/v4/clusters/{cluster_id}/permissions/{id}",
|
||||
"GET /ovirt-engine/api/v4/clusters/{id}",
|
||||
"GET /ovirt-engine/api/v4/datacenters",
|
||||
"GET /ovirt-engine/api/v4/datacenters/{datacenter_id}/clusters",
|
||||
"GET /ovirt-engine/api/v4/datacenters/{datacenter_id}/clusters/{id}",
|
||||
"GET /ovirt-engine/api/v4/datacenters/{datacenter_id}/networks",
|
||||
"GET /ovirt-engine/api/v4/datacenters/{datacenter_id}/networks/{id}",
|
||||
"GET /ovirt-engine/api/v4/datacenters/{datacenter_id}/permissions",
|
||||
"GET /ovirt-engine/api/v4/datacenters/{datacenter_id}/permissions/{id}",
|
||||
"GET /ovirt-engine/api/v4/datacenters/{datacenter_id}/quotas",
|
||||
"GET /ovirt-engine/api/v4/datacenters/{datacenter_id}/quotas/{id}",
|
||||
"GET /ovirt-engine/api/v4/datacenters/{datacenter_id}/storagedomains",
|
||||
"GET /ovirt-engine/api/v4/datacenters/{datacenter_id}/storagedomains/{id}",
|
||||
"GET /ovirt-engine/api/v4/datacenters/{id}",
|
||||
"GET /ovirt-engine/api/v4/disks",
|
||||
"GET /ovirt-engine/api/v4/disks/{id}",
|
||||
"GET /ovirt-engine/api/v4/domains",
|
||||
"GET /ovirt-engine/api/v4/domains/{id}",
|
||||
"GET /ovirt-engine/api/v4/events",
|
||||
"GET /ovirt-engine/api/v4/events/{id}",
|
||||
"GET /ovirt-engine/api/v4/externalhostproviders",
|
||||
"GET /ovirt-engine/api/v4/externalhostproviders/{id}",
|
||||
"GET /ovirt-engine/api/v4/groups",
|
||||
"GET /ovirt-engine/api/v4/groups/{id}",
|
||||
"GET /ovirt-engine/api/v4/hosts",
|
||||
"GET /ovirt-engine/api/v4/hosts/{host_id}/nics",
|
||||
"GET /ovirt-engine/api/v4/hosts/{host_id}/nics/{id}",
|
||||
"GET /ovirt-engine/api/v4/hosts/{host_id}/permissions",
|
||||
"GET /ovirt-engine/api/v4/hosts/{host_id}/permissions/{id}",
|
||||
"GET /ovirt-engine/api/v4/hosts/{host_id}/statistics",
|
||||
"GET /ovirt-engine/api/v4/hosts/{host_id}/statistics/{id}",
|
||||
"GET /ovirt-engine/api/v4/hosts/{host_id}/tags",
|
||||
"GET /ovirt-engine/api/v4/hosts/{host_id}/tags/{id}",
|
||||
"GET /ovirt-engine/api/v4/hosts/{id}",
|
||||
"GET /ovirt-engine/api/v4/icons",
|
||||
"GET /ovirt-engine/api/v4/icons/{id}",
|
||||
"GET /ovirt-engine/api/v4/imageTransfers",
|
||||
"GET /ovirt-engine/api/v4/imageTransfers/{id}",
|
||||
"GET /ovirt-engine/api/v4/instancetypes",
|
||||
"GET /ovirt-engine/api/v4/instancetypes/{id}",
|
||||
"GET /ovirt-engine/api/v4/jobs",
|
||||
"GET /ovirt-engine/api/v4/jobs/{id}",
|
||||
"GET /ovirt-engine/api/v4/jobs/{job_id}/steps",
|
||||
"GET /ovirt-engine/api/v4/jobs/{job_id}/steps/{id}",
|
||||
"GET /ovirt-engine/api/v4/katelloerrata",
|
||||
"GET /ovirt-engine/api/v4/katelloerrata/{id}",
|
||||
"GET /ovirt-engine/api/v4/macpools",
|
||||
"GET /ovirt-engine/api/v4/macpools/{id}",
|
||||
"GET /ovirt-engine/api/v4/networkfilters",
|
||||
"GET /ovirt-engine/api/v4/networkfilters/{id}",
|
||||
"GET /ovirt-engine/api/v4/networks",
|
||||
"GET /ovirt-engine/api/v4/networks/{id}",
|
||||
"GET /ovirt-engine/api/v4/networks/{network_id}/vnicprofiles",
|
||||
"GET /ovirt-engine/api/v4/networks/{network_id}/vnicprofiles/{id}",
|
||||
"GET /ovirt-engine/api/v4/openstackimageproviders",
|
||||
"GET /ovirt-engine/api/v4/openstackimageproviders/{id}",
|
||||
"GET /ovirt-engine/api/v4/openstacknetworkproviders",
|
||||
"GET /ovirt-engine/api/v4/openstacknetworkproviders/{id}",
|
||||
"GET /ovirt-engine/api/v4/openstackvolumeproviders",
|
||||
"GET /ovirt-engine/api/v4/openstackvolumeproviders/{id}",
|
||||
"GET /ovirt-engine/api/v4/operatingsystems",
|
||||
"GET /ovirt-engine/api/v4/operatingsystems/{id}",
|
||||
"GET /ovirt-engine/api/v4/permissions",
|
||||
"GET /ovirt-engine/api/v4/permissions/{id}",
|
||||
"GET /ovirt-engine/api/v4/roles",
|
||||
"GET /ovirt-engine/api/v4/roles/{id}",
|
||||
"GET /ovirt-engine/api/v4/schedulingpolicies",
|
||||
"GET /ovirt-engine/api/v4/schedulingpolicies/{id}",
|
||||
"GET /ovirt-engine/api/v4/schedulingpolicyunits",
|
||||
"GET /ovirt-engine/api/v4/schedulingpolicyunits/{id}",
|
||||
"GET /ovirt-engine/api/v4/storageconnections",
|
||||
"GET /ovirt-engine/api/v4/storageconnections/{id}",
|
||||
"GET /ovirt-engine/api/v4/storagedomains",
|
||||
"GET /ovirt-engine/api/v4/storagedomains/{id}",
|
||||
"GET /ovirt-engine/api/v4/storagedomains/{storagedomain_id}/disks",
|
||||
"GET /ovirt-engine/api/v4/storagedomains/{storagedomain_id}/disks/{id}",
|
||||
"GET /ovirt-engine/api/v4/storagedomains/{storagedomain_id}/files",
|
||||
"GET /ovirt-engine/api/v4/storagedomains/{storagedomain_id}/files/{id}",
|
||||
"GET /ovirt-engine/api/v4/tags",
|
||||
"GET /ovirt-engine/api/v4/tags/{id}",
|
||||
"GET /ovirt-engine/api/v4/templates",
|
||||
"GET /ovirt-engine/api/v4/templates/{id}",
|
||||
"GET /ovirt-engine/api/v4/templates/{template_id}/diskattachments",
|
||||
"GET /ovirt-engine/api/v4/templates/{template_id}/diskattachments/{id}",
|
||||
"GET /ovirt-engine/api/v4/templates/{template_id}/nics",
|
||||
"GET /ovirt-engine/api/v4/templates/{template_id}/nics/{id}",
|
||||
"GET /ovirt-engine/api/v4/users",
|
||||
"GET /ovirt-engine/api/v4/users/{id}",
|
||||
"GET /ovirt-engine/api/v4/vmpools",
|
||||
"GET /ovirt-engine/api/v4/vmpools/{id}",
|
||||
"GET /ovirt-engine/api/v4/vms",
|
||||
"GET /ovirt-engine/api/v4/vms/{id}",
|
||||
"GET /ovirt-engine/api/v4/vms/{vm_id}/cdroms",
|
||||
"GET /ovirt-engine/api/v4/vms/{vm_id}/cdroms/{id}",
|
||||
"GET /ovirt-engine/api/v4/vms/{vm_id}/diskattachments",
|
||||
"GET /ovirt-engine/api/v4/vms/{vm_id}/diskattachments/{id}",
|
||||
"GET /ovirt-engine/api/v4/vms/{vm_id}/graphicsconsoles",
|
||||
"GET /ovirt-engine/api/v4/vms/{vm_id}/graphicsconsoles/{id}",
|
||||
"GET /ovirt-engine/api/v4/vms/{vm_id}/nics",
|
||||
"GET /ovirt-engine/api/v4/vms/{vm_id}/nics/{id}",
|
||||
"GET /ovirt-engine/api/v4/vms/{vm_id}/permissions",
|
||||
"GET /ovirt-engine/api/v4/vms/{vm_id}/permissions/{id}",
|
||||
"GET /ovirt-engine/api/v4/vms/{vm_id}/snapshots",
|
||||
"GET /ovirt-engine/api/v4/vms/{vm_id}/snapshots/{id}",
|
||||
"GET /ovirt-engine/api/v4/vms/{vm_id}/tags",
|
||||
"GET /ovirt-engine/api/v4/vms/{vm_id}/tags/{id}",
|
||||
"GET /ovirt-engine/api/v4/vnicprofiles",
|
||||
"GET /ovirt-engine/api/v4/vnicprofiles/{id}",
|
||||
"POST /ovirt-engine/api/affinitylabels",
|
||||
"POST /ovirt-engine/api/hosts/{id}/enrollcertificate",
|
||||
"POST /ovirt-engine/api/imageTransfers",
|
||||
"POST /ovirt-engine/api/v4/affinitylabels",
|
||||
"POST /ovirt-engine/api/v4/bookmarks",
|
||||
"POST /ovirt-engine/api/v4/clusterlevels",
|
||||
"POST /ovirt-engine/api/v4/clusters",
|
||||
"POST /ovirt-engine/api/v4/clusters/{cluster_id}/affinitygroups",
|
||||
"POST /ovirt-engine/api/v4/clusters/{cluster_id}/networks",
|
||||
"POST /ovirt-engine/api/v4/clusters/{cluster_id}/permissions",
|
||||
"POST /ovirt-engine/api/v4/clusters/{id}/refreshglusterhealstatus",
|
||||
"POST /ovirt-engine/api/v4/clusters/{id}/resetemulatedmachine",
|
||||
"POST /ovirt-engine/api/v4/clusters/{id}/syncallnetworks"
|
||||
],
|
||||
"removed": [
|
||||
"DELETE /ovirt-engine/api/v3/bookmarks/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/clusterlevels/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/clusters/{cluster_id}/affinitygroups/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/clusters/{cluster_id}/networks/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/clusters/{cluster_id}/permissions/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/clusters/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/datacenters/{datacenter_id}/clusters/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/datacenters/{datacenter_id}/networks/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/datacenters/{datacenter_id}/permissions/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/datacenters/{datacenter_id}/quotas/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/datacenters/{datacenter_id}/storagedomains/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/datacenters/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/disks/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/domains/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/events/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/externalhostproviders/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/groups/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/hosts/{host_id}/nics/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/hosts/{host_id}/permissions/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/hosts/{host_id}/statistics/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/hosts/{host_id}/tags/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/hosts/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/icons/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/instancetypes/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/jobs/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/jobs/{job_id}/steps/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/katelloerrata/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/macpools/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/networkfilters/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/networks/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/networks/{network_id}/vnicprofiles/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/openstackimageproviders/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/openstacknetworkproviders/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/openstackvolumeproviders/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/operatingsystems/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/permissions/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/roles/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/schedulingpolicies/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/schedulingpolicyunits/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/storageconnections/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/storagedomains/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/storagedomains/{storagedomain_id}/disks/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/storagedomains/{storagedomain_id}/files/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/tags/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/templates/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/templates/{template_id}/diskattachments/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/templates/{template_id}/nics/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/users/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/vmpools/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/vms/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/vms/{vm_id}/cdroms/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/vms/{vm_id}/diskattachments/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/vms/{vm_id}/graphicsconsoles/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/vms/{vm_id}/nics/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/vms/{vm_id}/permissions/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/vms/{vm_id}/snapshots/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/vms/{vm_id}/tags/{id}",
|
||||
"DELETE /ovirt-engine/api/v3/vnicprofiles/{id}",
|
||||
"GET /ovirt-engine/api/v3",
|
||||
"GET /ovirt-engine/api/v3/bookmarks",
|
||||
"GET /ovirt-engine/api/v3/bookmarks/{id}",
|
||||
"GET /ovirt-engine/api/v3/clusterlevels",
|
||||
"GET /ovirt-engine/api/v3/clusterlevels/{id}",
|
||||
"GET /ovirt-engine/api/v3/clusters",
|
||||
"GET /ovirt-engine/api/v3/clusters/{cluster_id}/affinitygroups",
|
||||
"GET /ovirt-engine/api/v3/clusters/{cluster_id}/affinitygroups/{id}",
|
||||
"GET /ovirt-engine/api/v3/clusters/{cluster_id}/networks",
|
||||
"GET /ovirt-engine/api/v3/clusters/{cluster_id}/networks/{id}",
|
||||
"GET /ovirt-engine/api/v3/clusters/{cluster_id}/permissions",
|
||||
"GET /ovirt-engine/api/v3/clusters/{cluster_id}/permissions/{id}",
|
||||
"GET /ovirt-engine/api/v3/clusters/{id}",
|
||||
"GET /ovirt-engine/api/v3/datacenters",
|
||||
"GET /ovirt-engine/api/v3/datacenters/{datacenter_id}/clusters",
|
||||
"GET /ovirt-engine/api/v3/datacenters/{datacenter_id}/clusters/{id}",
|
||||
"GET /ovirt-engine/api/v3/datacenters/{datacenter_id}/networks",
|
||||
"GET /ovirt-engine/api/v3/datacenters/{datacenter_id}/networks/{id}",
|
||||
"GET /ovirt-engine/api/v3/datacenters/{datacenter_id}/permissions",
|
||||
"GET /ovirt-engine/api/v3/datacenters/{datacenter_id}/permissions/{id}",
|
||||
"GET /ovirt-engine/api/v3/datacenters/{datacenter_id}/quotas",
|
||||
"GET /ovirt-engine/api/v3/datacenters/{datacenter_id}/quotas/{id}",
|
||||
"GET /ovirt-engine/api/v3/datacenters/{datacenter_id}/storagedomains",
|
||||
"GET /ovirt-engine/api/v3/datacenters/{datacenter_id}/storagedomains/{id}",
|
||||
"GET /ovirt-engine/api/v3/datacenters/{id}",
|
||||
"GET /ovirt-engine/api/v3/disks",
|
||||
"GET /ovirt-engine/api/v3/disks/{id}",
|
||||
"GET /ovirt-engine/api/v3/domains",
|
||||
"GET /ovirt-engine/api/v3/domains/{id}",
|
||||
"GET /ovirt-engine/api/v3/events",
|
||||
"GET /ovirt-engine/api/v3/events/{id}",
|
||||
"GET /ovirt-engine/api/v3/externalhostproviders",
|
||||
"GET /ovirt-engine/api/v3/externalhostproviders/{id}",
|
||||
"GET /ovirt-engine/api/v3/groups",
|
||||
"GET /ovirt-engine/api/v3/groups/{id}",
|
||||
"GET /ovirt-engine/api/v3/hosts",
|
||||
"GET /ovirt-engine/api/v3/hosts/{host_id}/nics",
|
||||
"GET /ovirt-engine/api/v3/hosts/{host_id}/nics/{id}",
|
||||
"GET /ovirt-engine/api/v3/hosts/{host_id}/permissions",
|
||||
"GET /ovirt-engine/api/v3/hosts/{host_id}/permissions/{id}",
|
||||
"GET /ovirt-engine/api/v3/hosts/{host_id}/statistics",
|
||||
"GET /ovirt-engine/api/v3/hosts/{host_id}/statistics/{id}",
|
||||
"GET /ovirt-engine/api/v3/hosts/{host_id}/tags",
|
||||
"GET /ovirt-engine/api/v3/hosts/{host_id}/tags/{id}",
|
||||
"GET /ovirt-engine/api/v3/hosts/{id}",
|
||||
"GET /ovirt-engine/api/v3/icons",
|
||||
"GET /ovirt-engine/api/v3/icons/{id}",
|
||||
"GET /ovirt-engine/api/v3/instancetypes",
|
||||
"GET /ovirt-engine/api/v3/instancetypes/{id}",
|
||||
"GET /ovirt-engine/api/v3/jobs",
|
||||
"GET /ovirt-engine/api/v3/jobs/{id}",
|
||||
"GET /ovirt-engine/api/v3/jobs/{job_id}/steps",
|
||||
"GET /ovirt-engine/api/v3/jobs/{job_id}/steps/{id}",
|
||||
"GET /ovirt-engine/api/v3/katelloerrata",
|
||||
"GET /ovirt-engine/api/v3/katelloerrata/{id}",
|
||||
"GET /ovirt-engine/api/v3/macpools",
|
||||
"GET /ovirt-engine/api/v3/macpools/{id}",
|
||||
"GET /ovirt-engine/api/v3/networkfilters",
|
||||
"GET /ovirt-engine/api/v3/networkfilters/{id}",
|
||||
"GET /ovirt-engine/api/v3/networks",
|
||||
"GET /ovirt-engine/api/v3/networks/{id}",
|
||||
"GET /ovirt-engine/api/v3/networks/{network_id}/vnicprofiles",
|
||||
"GET /ovirt-engine/api/v3/networks/{network_id}/vnicprofiles/{id}",
|
||||
"GET /ovirt-engine/api/v3/openstackimageproviders",
|
||||
"GET /ovirt-engine/api/v3/openstackimageproviders/{id}",
|
||||
"GET /ovirt-engine/api/v3/openstacknetworkproviders",
|
||||
"GET /ovirt-engine/api/v3/openstacknetworkproviders/{id}",
|
||||
"GET /ovirt-engine/api/v3/openstackvolumeproviders",
|
||||
"GET /ovirt-engine/api/v3/openstackvolumeproviders/{id}",
|
||||
"GET /ovirt-engine/api/v3/operatingsystems",
|
||||
"GET /ovirt-engine/api/v3/operatingsystems/{id}",
|
||||
"GET /ovirt-engine/api/v3/permissions",
|
||||
"GET /ovirt-engine/api/v3/permissions/{id}",
|
||||
"GET /ovirt-engine/api/v3/roles",
|
||||
"GET /ovirt-engine/api/v3/roles/{id}",
|
||||
"GET /ovirt-engine/api/v3/schedulingpolicies",
|
||||
"GET /ovirt-engine/api/v3/schedulingpolicies/{id}",
|
||||
"GET /ovirt-engine/api/v3/schedulingpolicyunits",
|
||||
"GET /ovirt-engine/api/v3/schedulingpolicyunits/{id}",
|
||||
"GET /ovirt-engine/api/v3/storageconnections",
|
||||
"GET /ovirt-engine/api/v3/storageconnections/{id}",
|
||||
"GET /ovirt-engine/api/v3/storagedomains",
|
||||
"GET /ovirt-engine/api/v3/storagedomains/{id}",
|
||||
"GET /ovirt-engine/api/v3/storagedomains/{storagedomain_id}/disks",
|
||||
"GET /ovirt-engine/api/v3/storagedomains/{storagedomain_id}/disks/{id}",
|
||||
"GET /ovirt-engine/api/v3/storagedomains/{storagedomain_id}/files",
|
||||
"GET /ovirt-engine/api/v3/storagedomains/{storagedomain_id}/files/{id}",
|
||||
"GET /ovirt-engine/api/v3/tags",
|
||||
"GET /ovirt-engine/api/v3/tags/{id}",
|
||||
"GET /ovirt-engine/api/v3/templates",
|
||||
"GET /ovirt-engine/api/v3/templates/{id}",
|
||||
"GET /ovirt-engine/api/v3/templates/{template_id}/diskattachments",
|
||||
"GET /ovirt-engine/api/v3/templates/{template_id}/diskattachments/{id}",
|
||||
"GET /ovirt-engine/api/v3/templates/{template_id}/nics",
|
||||
"GET /ovirt-engine/api/v3/templates/{template_id}/nics/{id}",
|
||||
"GET /ovirt-engine/api/v3/users",
|
||||
"GET /ovirt-engine/api/v3/users/{id}",
|
||||
"GET /ovirt-engine/api/v3/vmpools",
|
||||
"GET /ovirt-engine/api/v3/vmpools/{id}",
|
||||
"GET /ovirt-engine/api/v3/vms",
|
||||
"GET /ovirt-engine/api/v3/vms/{id}",
|
||||
"GET /ovirt-engine/api/v3/vms/{vm_id}/cdroms",
|
||||
"GET /ovirt-engine/api/v3/vms/{vm_id}/cdroms/{id}",
|
||||
"GET /ovirt-engine/api/v3/vms/{vm_id}/diskattachments",
|
||||
"GET /ovirt-engine/api/v3/vms/{vm_id}/diskattachments/{id}",
|
||||
"GET /ovirt-engine/api/v3/vms/{vm_id}/graphicsconsoles",
|
||||
"GET /ovirt-engine/api/v3/vms/{vm_id}/graphicsconsoles/{id}",
|
||||
"GET /ovirt-engine/api/v3/vms/{vm_id}/nics",
|
||||
"GET /ovirt-engine/api/v3/vms/{vm_id}/nics/{id}",
|
||||
"GET /ovirt-engine/api/v3/vms/{vm_id}/permissions",
|
||||
"GET /ovirt-engine/api/v3/vms/{vm_id}/permissions/{id}",
|
||||
"GET /ovirt-engine/api/v3/vms/{vm_id}/snapshots",
|
||||
"GET /ovirt-engine/api/v3/vms/{vm_id}/snapshots/{id}",
|
||||
"GET /ovirt-engine/api/v3/vms/{vm_id}/tags",
|
||||
"GET /ovirt-engine/api/v3/vms/{vm_id}/tags/{id}",
|
||||
"GET /ovirt-engine/api/v3/vnicprofiles",
|
||||
"GET /ovirt-engine/api/v3/vnicprofiles/{id}",
|
||||
"POST /ovirt-engine/api/v3/bookmarks",
|
||||
"POST /ovirt-engine/api/v3/clusterlevels",
|
||||
"POST /ovirt-engine/api/v3/clusters",
|
||||
"POST /ovirt-engine/api/v3/clusters/{cluster_id}/affinitygroups",
|
||||
"POST /ovirt-engine/api/v3/clusters/{cluster_id}/networks",
|
||||
"POST /ovirt-engine/api/v3/clusters/{cluster_id}/permissions",
|
||||
"POST /ovirt-engine/api/v3/clusters/{id}/refreshglusterhealstatus",
|
||||
"POST /ovirt-engine/api/v3/clusters/{id}/resetemulatedmachine",
|
||||
"POST /ovirt-engine/api/v3/clusters/{id}/syncallnetworks",
|
||||
"POST /ovirt-engine/api/v3/clusters/{id}/upgrade",
|
||||
"POST /ovirt-engine/api/v3/datacenters",
|
||||
"POST /ovirt-engine/api/v3/datacenters/{datacenter_id}/clusters",
|
||||
"POST /ovirt-engine/api/v3/datacenters/{datacenter_id}/networks",
|
||||
"POST /ovirt-engine/api/v3/datacenters/{datacenter_id}/permissions",
|
||||
"POST /ovirt-engine/api/v3/datacenters/{datacenter_id}/quotas",
|
||||
"POST /ovirt-engine/api/v3/datacenters/{datacenter_id}/storagedomains",
|
||||
"POST /ovirt-engine/api/v3/datacenters/{datacenter_id}/storagedomains/{id}/activate",
|
||||
"POST /ovirt-engine/api/v3/datacenters/{datacenter_id}/storagedomains/{id}/deactivate",
|
||||
"POST /ovirt-engine/api/v3/datacenters/{id}/cleanfinishedtasks",
|
||||
"POST /ovirt-engine/api/v3/disks",
|
||||
"POST /ovirt-engine/api/v3/disks/{id}/copy",
|
||||
"POST /ovirt-engine/api/v3/disks/{id}/export",
|
||||
"POST /ovirt-engine/api/v3/disks/{id}/move",
|
||||
"POST /ovirt-engine/api/v3/disks/{id}/sparsify",
|
||||
"POST /ovirt-engine/api/v3/domains"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
{
|
||||
"series": "4.3",
|
||||
"major": 43,
|
||||
"api_version": "4",
|
||||
"product": {
|
||||
"major": "4",
|
||||
"minor": "3",
|
||||
"full": "4.3.0"
|
||||
},
|
||||
"operation_count": 706,
|
||||
"service_count": 1,
|
||||
"services": [
|
||||
"engine"
|
||||
],
|
||||
"checksum": "c4aaed52cd6ffe3d6f2ece2e3b26117fdae52f043ae9d1c4f1be6848338a892b",
|
||||
"generated_at": "2026-07-16T04:31:47Z",
|
||||
"entry_point_links": [
|
||||
{
|
||||
"rel": "affinitylabels",
|
||||
"href": "/ovirt-engine/api/affinitylabels"
|
||||
},
|
||||
{
|
||||
"rel": "bookmarks",
|
||||
"href": "/ovirt-engine/api/bookmarks"
|
||||
},
|
||||
{
|
||||
"rel": "clusterlevels",
|
||||
"href": "/ovirt-engine/api/clusterlevels"
|
||||
},
|
||||
{
|
||||
"rel": "clusters",
|
||||
"href": "/ovirt-engine/api/clusters"
|
||||
},
|
||||
{
|
||||
"rel": "datacenters",
|
||||
"href": "/ovirt-engine/api/datacenters"
|
||||
},
|
||||
{
|
||||
"rel": "disks",
|
||||
"href": "/ovirt-engine/api/disks"
|
||||
},
|
||||
{
|
||||
"rel": "domains",
|
||||
"href": "/ovirt-engine/api/domains"
|
||||
},
|
||||
{
|
||||
"rel": "events",
|
||||
"href": "/ovirt-engine/api/events"
|
||||
},
|
||||
{
|
||||
"rel": "externalhostproviders",
|
||||
"href": "/ovirt-engine/api/externalhostproviders"
|
||||
},
|
||||
{
|
||||
"rel": "groups",
|
||||
"href": "/ovirt-engine/api/groups"
|
||||
},
|
||||
{
|
||||
"rel": "hosts",
|
||||
"href": "/ovirt-engine/api/hosts"
|
||||
},
|
||||
{
|
||||
"rel": "icons",
|
||||
"href": "/ovirt-engine/api/icons"
|
||||
},
|
||||
{
|
||||
"rel": "instancetypes",
|
||||
"href": "/ovirt-engine/api/instancetypes"
|
||||
},
|
||||
{
|
||||
"rel": "jobs",
|
||||
"href": "/ovirt-engine/api/jobs"
|
||||
},
|
||||
{
|
||||
"rel": "katelloerrata",
|
||||
"href": "/ovirt-engine/api/katelloerrata"
|
||||
},
|
||||
{
|
||||
"rel": "macpools",
|
||||
"href": "/ovirt-engine/api/macpools"
|
||||
},
|
||||
{
|
||||
"rel": "networkfilters",
|
||||
"href": "/ovirt-engine/api/networkfilters"
|
||||
},
|
||||
{
|
||||
"rel": "networks",
|
||||
"href": "/ovirt-engine/api/networks"
|
||||
},
|
||||
{
|
||||
"rel": "openstackimageproviders",
|
||||
"href": "/ovirt-engine/api/openstackimageproviders"
|
||||
},
|
||||
{
|
||||
"rel": "openstacknetworkproviders",
|
||||
"href": "/ovirt-engine/api/openstacknetworkproviders"
|
||||
},
|
||||
{
|
||||
"rel": "openstackvolumeproviders",
|
||||
"href": "/ovirt-engine/api/openstackvolumeproviders"
|
||||
},
|
||||
{
|
||||
"rel": "operatingsystems",
|
||||
"href": "/ovirt-engine/api/operatingsystems"
|
||||
},
|
||||
{
|
||||
"rel": "permissions",
|
||||
"href": "/ovirt-engine/api/permissions"
|
||||
},
|
||||
{
|
||||
"rel": "roles",
|
||||
"href": "/ovirt-engine/api/roles"
|
||||
},
|
||||
{
|
||||
"rel": "schedulingpolicies",
|
||||
"href": "/ovirt-engine/api/schedulingpolicies"
|
||||
},
|
||||
{
|
||||
"rel": "schedulingpolicyunits",
|
||||
"href": "/ovirt-engine/api/schedulingpolicyunits"
|
||||
},
|
||||
{
|
||||
"rel": "storageconnections",
|
||||
"href": "/ovirt-engine/api/storageconnections"
|
||||
},
|
||||
{
|
||||
"rel": "storagedomains",
|
||||
"href": "/ovirt-engine/api/storagedomains"
|
||||
},
|
||||
{
|
||||
"rel": "tags",
|
||||
"href": "/ovirt-engine/api/tags"
|
||||
},
|
||||
{
|
||||
"rel": "templates",
|
||||
"href": "/ovirt-engine/api/templates"
|
||||
},
|
||||
{
|
||||
"rel": "users",
|
||||
"href": "/ovirt-engine/api/users"
|
||||
},
|
||||
{
|
||||
"rel": "vmpools",
|
||||
"href": "/ovirt-engine/api/vmpools"
|
||||
},
|
||||
{
|
||||
"rel": "vms",
|
||||
"href": "/ovirt-engine/api/vms"
|
||||
},
|
||||
{
|
||||
"rel": "vnicprofiles",
|
||||
"href": "/ovirt-engine/api/vnicprofiles"
|
||||
},
|
||||
{
|
||||
"rel": "imagetransfers",
|
||||
"href": "/ovirt-engine/api/imageTransfers"
|
||||
}
|
||||
],
|
||||
"deltas": {
|
||||
"added": 364,
|
||||
"removed": 342
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"series": "4.4",
|
||||
"previous": "4.3",
|
||||
"added_count": 14,
|
||||
"removed_count": 0,
|
||||
"added": [
|
||||
"DELETE /ovirt-engine/api/options/{id}",
|
||||
"DELETE /ovirt-engine/api/v4/options/{id}",
|
||||
"GET /ovirt-engine/api/options",
|
||||
"GET /ovirt-engine/api/options/{id}",
|
||||
"GET /ovirt-engine/api/v4/options",
|
||||
"GET /ovirt-engine/api/v4/options/{id}",
|
||||
"POST /ovirt-engine/api/disks/{id}/reduce",
|
||||
"POST /ovirt-engine/api/options",
|
||||
"POST /ovirt-engine/api/storagedomains/{id}/reduceluns",
|
||||
"POST /ovirt-engine/api/v4/disks/{id}/reduce",
|
||||
"POST /ovirt-engine/api/v4/options",
|
||||
"POST /ovirt-engine/api/v4/storagedomains/{id}/reduceluns",
|
||||
"PUT /ovirt-engine/api/options/{id}",
|
||||
"PUT /ovirt-engine/api/v4/options/{id}"
|
||||
],
|
||||
"removed": []
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
{
|
||||
"series": "4.4",
|
||||
"major": 44,
|
||||
"api_version": "4",
|
||||
"product": {
|
||||
"major": "4",
|
||||
"minor": "4",
|
||||
"full": "4.4.0"
|
||||
},
|
||||
"operation_count": 720,
|
||||
"service_count": 1,
|
||||
"services": [
|
||||
"engine"
|
||||
],
|
||||
"checksum": "1d90616e31930cac0c0102ce1b4279bae661f6ea62c2a4ced3f6c7a61349130c",
|
||||
"generated_at": "2026-07-16T04:31:47Z",
|
||||
"entry_point_links": [
|
||||
{
|
||||
"rel": "affinitylabels",
|
||||
"href": "/ovirt-engine/api/affinitylabels"
|
||||
},
|
||||
{
|
||||
"rel": "bookmarks",
|
||||
"href": "/ovirt-engine/api/bookmarks"
|
||||
},
|
||||
{
|
||||
"rel": "clusterlevels",
|
||||
"href": "/ovirt-engine/api/clusterlevels"
|
||||
},
|
||||
{
|
||||
"rel": "clusters",
|
||||
"href": "/ovirt-engine/api/clusters"
|
||||
},
|
||||
{
|
||||
"rel": "datacenters",
|
||||
"href": "/ovirt-engine/api/datacenters"
|
||||
},
|
||||
{
|
||||
"rel": "disks",
|
||||
"href": "/ovirt-engine/api/disks"
|
||||
},
|
||||
{
|
||||
"rel": "domains",
|
||||
"href": "/ovirt-engine/api/domains"
|
||||
},
|
||||
{
|
||||
"rel": "events",
|
||||
"href": "/ovirt-engine/api/events"
|
||||
},
|
||||
{
|
||||
"rel": "externalhostproviders",
|
||||
"href": "/ovirt-engine/api/externalhostproviders"
|
||||
},
|
||||
{
|
||||
"rel": "groups",
|
||||
"href": "/ovirt-engine/api/groups"
|
||||
},
|
||||
{
|
||||
"rel": "hosts",
|
||||
"href": "/ovirt-engine/api/hosts"
|
||||
},
|
||||
{
|
||||
"rel": "icons",
|
||||
"href": "/ovirt-engine/api/icons"
|
||||
},
|
||||
{
|
||||
"rel": "instancetypes",
|
||||
"href": "/ovirt-engine/api/instancetypes"
|
||||
},
|
||||
{
|
||||
"rel": "jobs",
|
||||
"href": "/ovirt-engine/api/jobs"
|
||||
},
|
||||
{
|
||||
"rel": "katelloerrata",
|
||||
"href": "/ovirt-engine/api/katelloerrata"
|
||||
},
|
||||
{
|
||||
"rel": "macpools",
|
||||
"href": "/ovirt-engine/api/macpools"
|
||||
},
|
||||
{
|
||||
"rel": "networkfilters",
|
||||
"href": "/ovirt-engine/api/networkfilters"
|
||||
},
|
||||
{
|
||||
"rel": "networks",
|
||||
"href": "/ovirt-engine/api/networks"
|
||||
},
|
||||
{
|
||||
"rel": "openstackimageproviders",
|
||||
"href": "/ovirt-engine/api/openstackimageproviders"
|
||||
},
|
||||
{
|
||||
"rel": "openstacknetworkproviders",
|
||||
"href": "/ovirt-engine/api/openstacknetworkproviders"
|
||||
},
|
||||
{
|
||||
"rel": "openstackvolumeproviders",
|
||||
"href": "/ovirt-engine/api/openstackvolumeproviders"
|
||||
},
|
||||
{
|
||||
"rel": "operatingsystems",
|
||||
"href": "/ovirt-engine/api/operatingsystems"
|
||||
},
|
||||
{
|
||||
"rel": "permissions",
|
||||
"href": "/ovirt-engine/api/permissions"
|
||||
},
|
||||
{
|
||||
"rel": "roles",
|
||||
"href": "/ovirt-engine/api/roles"
|
||||
},
|
||||
{
|
||||
"rel": "schedulingpolicies",
|
||||
"href": "/ovirt-engine/api/schedulingpolicies"
|
||||
},
|
||||
{
|
||||
"rel": "schedulingpolicyunits",
|
||||
"href": "/ovirt-engine/api/schedulingpolicyunits"
|
||||
},
|
||||
{
|
||||
"rel": "storageconnections",
|
||||
"href": "/ovirt-engine/api/storageconnections"
|
||||
},
|
||||
{
|
||||
"rel": "storagedomains",
|
||||
"href": "/ovirt-engine/api/storagedomains"
|
||||
},
|
||||
{
|
||||
"rel": "tags",
|
||||
"href": "/ovirt-engine/api/tags"
|
||||
},
|
||||
{
|
||||
"rel": "templates",
|
||||
"href": "/ovirt-engine/api/templates"
|
||||
},
|
||||
{
|
||||
"rel": "users",
|
||||
"href": "/ovirt-engine/api/users"
|
||||
},
|
||||
{
|
||||
"rel": "vmpools",
|
||||
"href": "/ovirt-engine/api/vmpools"
|
||||
},
|
||||
{
|
||||
"rel": "vms",
|
||||
"href": "/ovirt-engine/api/vms"
|
||||
},
|
||||
{
|
||||
"rel": "vnicprofiles",
|
||||
"href": "/ovirt-engine/api/vnicprofiles"
|
||||
},
|
||||
{
|
||||
"rel": "imagetransfers",
|
||||
"href": "/ovirt-engine/api/imageTransfers"
|
||||
},
|
||||
{
|
||||
"rel": "options",
|
||||
"href": "/ovirt-engine/api/options"
|
||||
}
|
||||
],
|
||||
"deltas": {
|
||||
"added": 14,
|
||||
"removed": 0
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"series": "4.5",
|
||||
"previous": "4.4",
|
||||
"added_count": 0,
|
||||
"removed_count": 0,
|
||||
"added": [],
|
||||
"removed": []
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
{
|
||||
"series": "4.5",
|
||||
"major": 45,
|
||||
"api_version": "4",
|
||||
"product": {
|
||||
"major": "4",
|
||||
"minor": "5",
|
||||
"full": "4.5.0"
|
||||
},
|
||||
"operation_count": 720,
|
||||
"service_count": 1,
|
||||
"services": [
|
||||
"engine"
|
||||
],
|
||||
"checksum": "729a789a4d2d63813b89ee9a7f98669eefbbdc98974416e7f6e7c68d5cb15ec7",
|
||||
"generated_at": "2026-07-16T04:31:47Z",
|
||||
"entry_point_links": [
|
||||
{
|
||||
"rel": "affinitylabels",
|
||||
"href": "/ovirt-engine/api/affinitylabels"
|
||||
},
|
||||
{
|
||||
"rel": "bookmarks",
|
||||
"href": "/ovirt-engine/api/bookmarks"
|
||||
},
|
||||
{
|
||||
"rel": "clusterlevels",
|
||||
"href": "/ovirt-engine/api/clusterlevels"
|
||||
},
|
||||
{
|
||||
"rel": "clusters",
|
||||
"href": "/ovirt-engine/api/clusters"
|
||||
},
|
||||
{
|
||||
"rel": "datacenters",
|
||||
"href": "/ovirt-engine/api/datacenters"
|
||||
},
|
||||
{
|
||||
"rel": "disks",
|
||||
"href": "/ovirt-engine/api/disks"
|
||||
},
|
||||
{
|
||||
"rel": "domains",
|
||||
"href": "/ovirt-engine/api/domains"
|
||||
},
|
||||
{
|
||||
"rel": "events",
|
||||
"href": "/ovirt-engine/api/events"
|
||||
},
|
||||
{
|
||||
"rel": "externalhostproviders",
|
||||
"href": "/ovirt-engine/api/externalhostproviders"
|
||||
},
|
||||
{
|
||||
"rel": "groups",
|
||||
"href": "/ovirt-engine/api/groups"
|
||||
},
|
||||
{
|
||||
"rel": "hosts",
|
||||
"href": "/ovirt-engine/api/hosts"
|
||||
},
|
||||
{
|
||||
"rel": "icons",
|
||||
"href": "/ovirt-engine/api/icons"
|
||||
},
|
||||
{
|
||||
"rel": "instancetypes",
|
||||
"href": "/ovirt-engine/api/instancetypes"
|
||||
},
|
||||
{
|
||||
"rel": "jobs",
|
||||
"href": "/ovirt-engine/api/jobs"
|
||||
},
|
||||
{
|
||||
"rel": "katelloerrata",
|
||||
"href": "/ovirt-engine/api/katelloerrata"
|
||||
},
|
||||
{
|
||||
"rel": "macpools",
|
||||
"href": "/ovirt-engine/api/macpools"
|
||||
},
|
||||
{
|
||||
"rel": "networkfilters",
|
||||
"href": "/ovirt-engine/api/networkfilters"
|
||||
},
|
||||
{
|
||||
"rel": "networks",
|
||||
"href": "/ovirt-engine/api/networks"
|
||||
},
|
||||
{
|
||||
"rel": "openstackimageproviders",
|
||||
"href": "/ovirt-engine/api/openstackimageproviders"
|
||||
},
|
||||
{
|
||||
"rel": "openstacknetworkproviders",
|
||||
"href": "/ovirt-engine/api/openstacknetworkproviders"
|
||||
},
|
||||
{
|
||||
"rel": "openstackvolumeproviders",
|
||||
"href": "/ovirt-engine/api/openstackvolumeproviders"
|
||||
},
|
||||
{
|
||||
"rel": "operatingsystems",
|
||||
"href": "/ovirt-engine/api/operatingsystems"
|
||||
},
|
||||
{
|
||||
"rel": "permissions",
|
||||
"href": "/ovirt-engine/api/permissions"
|
||||
},
|
||||
{
|
||||
"rel": "roles",
|
||||
"href": "/ovirt-engine/api/roles"
|
||||
},
|
||||
{
|
||||
"rel": "schedulingpolicies",
|
||||
"href": "/ovirt-engine/api/schedulingpolicies"
|
||||
},
|
||||
{
|
||||
"rel": "schedulingpolicyunits",
|
||||
"href": "/ovirt-engine/api/schedulingpolicyunits"
|
||||
},
|
||||
{
|
||||
"rel": "storageconnections",
|
||||
"href": "/ovirt-engine/api/storageconnections"
|
||||
},
|
||||
{
|
||||
"rel": "storagedomains",
|
||||
"href": "/ovirt-engine/api/storagedomains"
|
||||
},
|
||||
{
|
||||
"rel": "tags",
|
||||
"href": "/ovirt-engine/api/tags"
|
||||
},
|
||||
{
|
||||
"rel": "templates",
|
||||
"href": "/ovirt-engine/api/templates"
|
||||
},
|
||||
{
|
||||
"rel": "users",
|
||||
"href": "/ovirt-engine/api/users"
|
||||
},
|
||||
{
|
||||
"rel": "vmpools",
|
||||
"href": "/ovirt-engine/api/vmpools"
|
||||
},
|
||||
{
|
||||
"rel": "vms",
|
||||
"href": "/ovirt-engine/api/vms"
|
||||
},
|
||||
{
|
||||
"rel": "vnicprofiles",
|
||||
"href": "/ovirt-engine/api/vnicprofiles"
|
||||
},
|
||||
{
|
||||
"rel": "imagetransfers",
|
||||
"href": "/ovirt-engine/api/imageTransfers"
|
||||
},
|
||||
{
|
||||
"rel": "options",
|
||||
"href": "/ovirt-engine/api/options"
|
||||
}
|
||||
],
|
||||
"deltas": {
|
||||
"added": 0,
|
||||
"removed": 0
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"series": "master",
|
||||
"previous": "4.5",
|
||||
"added_count": 0,
|
||||
"removed_count": 0,
|
||||
"added": [],
|
||||
"removed": []
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
{
|
||||
"series": "master",
|
||||
"major": 50,
|
||||
"api_version": "4",
|
||||
"product": {
|
||||
"major": "4",
|
||||
"minor": "6",
|
||||
"full": "4.6.0-master"
|
||||
},
|
||||
"operation_count": 720,
|
||||
"service_count": 1,
|
||||
"services": [
|
||||
"engine"
|
||||
],
|
||||
"checksum": "843849e0634170a061e2c91cd63831f46859bb82ef025d1da46074ddadc332ae",
|
||||
"generated_at": "2026-07-16T04:31:47Z",
|
||||
"entry_point_links": [
|
||||
{
|
||||
"rel": "affinitylabels",
|
||||
"href": "/ovirt-engine/api/affinitylabels"
|
||||
},
|
||||
{
|
||||
"rel": "bookmarks",
|
||||
"href": "/ovirt-engine/api/bookmarks"
|
||||
},
|
||||
{
|
||||
"rel": "clusterlevels",
|
||||
"href": "/ovirt-engine/api/clusterlevels"
|
||||
},
|
||||
{
|
||||
"rel": "clusters",
|
||||
"href": "/ovirt-engine/api/clusters"
|
||||
},
|
||||
{
|
||||
"rel": "datacenters",
|
||||
"href": "/ovirt-engine/api/datacenters"
|
||||
},
|
||||
{
|
||||
"rel": "disks",
|
||||
"href": "/ovirt-engine/api/disks"
|
||||
},
|
||||
{
|
||||
"rel": "domains",
|
||||
"href": "/ovirt-engine/api/domains"
|
||||
},
|
||||
{
|
||||
"rel": "events",
|
||||
"href": "/ovirt-engine/api/events"
|
||||
},
|
||||
{
|
||||
"rel": "externalhostproviders",
|
||||
"href": "/ovirt-engine/api/externalhostproviders"
|
||||
},
|
||||
{
|
||||
"rel": "groups",
|
||||
"href": "/ovirt-engine/api/groups"
|
||||
},
|
||||
{
|
||||
"rel": "hosts",
|
||||
"href": "/ovirt-engine/api/hosts"
|
||||
},
|
||||
{
|
||||
"rel": "icons",
|
||||
"href": "/ovirt-engine/api/icons"
|
||||
},
|
||||
{
|
||||
"rel": "instancetypes",
|
||||
"href": "/ovirt-engine/api/instancetypes"
|
||||
},
|
||||
{
|
||||
"rel": "jobs",
|
||||
"href": "/ovirt-engine/api/jobs"
|
||||
},
|
||||
{
|
||||
"rel": "katelloerrata",
|
||||
"href": "/ovirt-engine/api/katelloerrata"
|
||||
},
|
||||
{
|
||||
"rel": "macpools",
|
||||
"href": "/ovirt-engine/api/macpools"
|
||||
},
|
||||
{
|
||||
"rel": "networkfilters",
|
||||
"href": "/ovirt-engine/api/networkfilters"
|
||||
},
|
||||
{
|
||||
"rel": "networks",
|
||||
"href": "/ovirt-engine/api/networks"
|
||||
},
|
||||
{
|
||||
"rel": "openstackimageproviders",
|
||||
"href": "/ovirt-engine/api/openstackimageproviders"
|
||||
},
|
||||
{
|
||||
"rel": "openstacknetworkproviders",
|
||||
"href": "/ovirt-engine/api/openstacknetworkproviders"
|
||||
},
|
||||
{
|
||||
"rel": "openstackvolumeproviders",
|
||||
"href": "/ovirt-engine/api/openstackvolumeproviders"
|
||||
},
|
||||
{
|
||||
"rel": "operatingsystems",
|
||||
"href": "/ovirt-engine/api/operatingsystems"
|
||||
},
|
||||
{
|
||||
"rel": "permissions",
|
||||
"href": "/ovirt-engine/api/permissions"
|
||||
},
|
||||
{
|
||||
"rel": "roles",
|
||||
"href": "/ovirt-engine/api/roles"
|
||||
},
|
||||
{
|
||||
"rel": "schedulingpolicies",
|
||||
"href": "/ovirt-engine/api/schedulingpolicies"
|
||||
},
|
||||
{
|
||||
"rel": "schedulingpolicyunits",
|
||||
"href": "/ovirt-engine/api/schedulingpolicyunits"
|
||||
},
|
||||
{
|
||||
"rel": "storageconnections",
|
||||
"href": "/ovirt-engine/api/storageconnections"
|
||||
},
|
||||
{
|
||||
"rel": "storagedomains",
|
||||
"href": "/ovirt-engine/api/storagedomains"
|
||||
},
|
||||
{
|
||||
"rel": "tags",
|
||||
"href": "/ovirt-engine/api/tags"
|
||||
},
|
||||
{
|
||||
"rel": "templates",
|
||||
"href": "/ovirt-engine/api/templates"
|
||||
},
|
||||
{
|
||||
"rel": "users",
|
||||
"href": "/ovirt-engine/api/users"
|
||||
},
|
||||
{
|
||||
"rel": "vmpools",
|
||||
"href": "/ovirt-engine/api/vmpools"
|
||||
},
|
||||
{
|
||||
"rel": "vms",
|
||||
"href": "/ovirt-engine/api/vms"
|
||||
},
|
||||
{
|
||||
"rel": "vnicprofiles",
|
||||
"href": "/ovirt-engine/api/vnicprofiles"
|
||||
},
|
||||
{
|
||||
"rel": "imagetransfers",
|
||||
"href": "/ovirt-engine/api/imageTransfers"
|
||||
},
|
||||
{
|
||||
"rel": "options",
|
||||
"href": "/ovirt-engine/api/options"
|
||||
}
|
||||
],
|
||||
"deltas": {
|
||||
"added": 0,
|
||||
"removed": 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
# Quick start with the published Docker Hub runtime image.
|
||||
#
|
||||
# docker compose -f docker-compose.release.yml up -d
|
||||
# docker compose -f docker-compose.release.yml run --rm --entrypoint python \
|
||||
# simulator -m app.ovirt.seed_cli --profile minimal
|
||||
#
|
||||
# Override the image tag:
|
||||
# IMAGE_TAG=0.1.0 docker compose -f docker-compose.release.yml up -d
|
||||
#
|
||||
# Change TICKET_SIGNING_KEY before exposing the stack beyond a local lab.
|
||||
|
||||
name: ovirt-api-simulator-release
|
||||
|
||||
x-app-image: &app-image
|
||||
image: ${DOCKER_IMAGE:-inecs/ovirt-api-simulator}:${IMAGE_TAG:-0.1.0}
|
||||
|
||||
x-app-env: &app-env
|
||||
DATABASE_URL: postgresql://ovirt:ovirt@postgres:5432/ovirt_simulator
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
TICKET_SIGNING_KEY: ${TICKET_SIGNING_KEY:-development-only-signing-key-change-me}
|
||||
OVIRT_SERIES: ${OVIRT_SERIES:-4.5}
|
||||
|
||||
networks:
|
||||
simulator:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17.5-bookworm
|
||||
restart: unless-stopped
|
||||
networks: [simulator]
|
||||
environment:
|
||||
POSTGRES_DB: ovirt_simulator
|
||||
POSTGRES_USER: ovirt
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ovirt}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ovirt -d ovirt_simulator"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
start_period: 5s
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
|
||||
migrate:
|
||||
<<: *app-image
|
||||
networks: [simulator]
|
||||
environment:
|
||||
<<: *app-env
|
||||
DATABASE_URL: postgresql://ovirt:${POSTGRES_PASSWORD:-ovirt}@postgres:5432/ovirt_simulator
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
entrypoint: ["python"]
|
||||
command: ["-m", "app.db.migrate_cli"]
|
||||
restart: "no"
|
||||
|
||||
simulator:
|
||||
<<: *app-image
|
||||
restart: unless-stopped
|
||||
networks: [simulator]
|
||||
environment:
|
||||
<<: *app-env
|
||||
DATABASE_URL: postgresql://ovirt:${POSTGRES_PASSWORD:-ovirt}@postgres:5432/ovirt_simulator
|
||||
depends_on:
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"python",
|
||||
"-c",
|
||||
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/ready', timeout=2)",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 8
|
||||
start_period: 20s
|
||||
expose:
|
||||
- "8080"
|
||||
|
||||
api-gateway:
|
||||
image: nginx:1.28.0-alpine
|
||||
restart: unless-stopped
|
||||
networks: [simulator]
|
||||
depends_on:
|
||||
simulator:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${OVIRT_ENGINE_PORT:-443}:443"
|
||||
- "${OVIRT_UI_PORT:-5000}:5000"
|
||||
volumes:
|
||||
- ./docker/gateway/ovirt-engine.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ./docker/tls/server.crt:/etc/nginx/tls/server.crt:ro
|
||||
- ./docker/tls/server.key:/etc/nginx/tls/server.key:ro
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"wget -qO- http://127.0.0.1:5000/health/live || exit 1",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /var/cache/nginx
|
||||
- /var/run
|
||||
- /tmp
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
@@ -0,0 +1,164 @@
|
||||
name: ovirt-api-simulator
|
||||
|
||||
x-simulator-env: &simulator-env
|
||||
DATABASE_URL: postgresql://ovirt:ovirt@postgres:5432/ovirt_simulator
|
||||
LOG_LEVEL: INFO
|
||||
TICKET_SIGNING_KEY: development-only-signing-key-change-me
|
||||
APP_PORT: "8080"
|
||||
OVIRT_SERIES: "4.5"
|
||||
|
||||
x-dev-env: &dev-env
|
||||
DATABASE_URL: postgresql://ovirt:ovirt@postgres:5432/ovirt_simulator
|
||||
TEST_DATABASE_URL: postgresql://ovirt:ovirt@postgres:5432/ovirt_simulator
|
||||
LOG_LEVEL: INFO
|
||||
TICKET_SIGNING_KEY: development-only-signing-key-change-me
|
||||
APP_PORT: "8080"
|
||||
OVIRT_SERIES: "4.5"
|
||||
|
||||
networks:
|
||||
simulator:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17.5-bookworm
|
||||
restart: unless-stopped
|
||||
networks: [simulator]
|
||||
environment:
|
||||
POSTGRES_DB: ovirt_simulator
|
||||
POSTGRES_USER: ovirt
|
||||
POSTGRES_PASSWORD: ovirt
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ovirt -d ovirt_simulator"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
start_period: 5s
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
|
||||
migrate:
|
||||
build:
|
||||
context: .
|
||||
target: runtime
|
||||
image: ovirt-api-simulator:0.1.0
|
||||
networks: [simulator]
|
||||
working_dir: /workspace
|
||||
volumes:
|
||||
- .:/workspace
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
environment:
|
||||
<<: *simulator-env
|
||||
PYTHONPATH: /workspace
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
entrypoint: ["python"]
|
||||
command: ["-m", "app.db.migrate_cli"]
|
||||
restart: "no"
|
||||
|
||||
simulator:
|
||||
build:
|
||||
context: .
|
||||
target: dev
|
||||
image: ovirt-api-simulator-dev:0.1.0
|
||||
restart: unless-stopped
|
||||
networks: [simulator]
|
||||
working_dir: /workspace
|
||||
volumes:
|
||||
- .:/workspace
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
environment:
|
||||
<<: *dev-env
|
||||
depends_on:
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
entrypoint: []
|
||||
command:
|
||||
[
|
||||
"uvicorn",
|
||||
"app.main:app",
|
||||
"--host",
|
||||
"0.0.0.0",
|
||||
"--port",
|
||||
"8080",
|
||||
"--reload",
|
||||
"--reload-dir",
|
||||
"/workspace/app",
|
||||
"--reload-include",
|
||||
"*.html",
|
||||
]
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"python",
|
||||
"-c",
|
||||
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/ready', timeout=2)",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 8
|
||||
start_period: 25s
|
||||
expose:
|
||||
- "8080"
|
||||
|
||||
api-gateway:
|
||||
image: nginx:1.28.0-alpine
|
||||
restart: unless-stopped
|
||||
networks: [simulator]
|
||||
depends_on:
|
||||
simulator:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
# Only two host ports (Engine HTTPS + Web UI). Override via env if needed.
|
||||
- "${OVIRT_ENGINE_PORT:-443}:443" # Engine HTTPS (/ovirt-engine/api, SSO)
|
||||
- "${OVIRT_UI_PORT:-5000}:5000" # Web UI console
|
||||
volumes:
|
||||
- ./docker/gateway/ovirt-engine.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ./docker/tls/server.crt:/etc/nginx/tls/server.crt:ro
|
||||
- ./docker/tls/server.key:/etc/nginx/tls/server.key:ro
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"wget -qO- http://127.0.0.1:5000/health/live || exit 1",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /var/cache/nginx
|
||||
- /var/run
|
||||
- /tmp
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
dev:
|
||||
profiles: [tools]
|
||||
build:
|
||||
context: .
|
||||
target: dev
|
||||
image: ovirt-api-simulator-dev:0.1.0
|
||||
networks: [simulator]
|
||||
working_dir: /workspace
|
||||
volumes:
|
||||
- .:/workspace
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
environment:
|
||||
<<: *dev-env
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
entrypoint: []
|
||||
@@ -0,0 +1,49 @@
|
||||
# nginx gateway — only two public listeners:
|
||||
# :443 Engine API + SSO (/ovirt-engine/…)
|
||||
# :5000 Web UI console
|
||||
# Upstream FastAPI listens on simulator:8080.
|
||||
|
||||
upstream ovirt_simulator {
|
||||
server simulator:8080;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
# --- Engine HTTPS (API + SSO) ---
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name _;
|
||||
|
||||
ssl_certificate /etc/nginx/tls/server.crt;
|
||||
ssl_certificate_key /etc/nginx/tls/server.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
|
||||
client_max_body_size 64m;
|
||||
|
||||
location / {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_pass http://ovirt_simulator;
|
||||
}
|
||||
}
|
||||
|
||||
# --- Simulator Web UI console ---
|
||||
server {
|
||||
listen 5000;
|
||||
listen [::]:5000;
|
||||
server_name _;
|
||||
|
||||
location / {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Port 5000;
|
||||
proxy_pass http://ovirt_simulator;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIICyTCCAbGgAwIBAgIJAIbJhnhVx8uWMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV
|
||||
BAMMCWxvY2FsaG9zdDAeFw0yNjA3MTIyMTQyNTFaFw0zNjA3MDkyMTQyNTFaMBQx
|
||||
EjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC
|
||||
ggEBALscj7/1WDybjz8x01EvUFVemov6zkezOwfsOXKVyEOnOTxPjWruzDYnB8y6
|
||||
NH/5PojUns7GB1kuRhZWUXGY0FG/sSgF0X9nwEHoby8ekju2F55NUzzpu9BfM2AU
|
||||
S17S8h5Oxc4Qi6d9RoeRG25YmMywPCyp2SMnuu14w55KTAt7Ir7mbTAv8ZIMbVhq
|
||||
34tH45ONQvGftN4JNvwZr7Uf+EuupWsnILfkz1Cw1cj88adDZHwxE7Hkx7TiQP6o
|
||||
DPDeg+XYH0vB2HR25JSP9z0uyeeF6n6cExgfwVZy2una7jQp887N5xLgTUGlnmFM
|
||||
y1z2AO2+Mw1Lh2UC/OQrp9T1ztMCAwEAAaMeMBwwGgYDVR0RBBMwEYIJbG9jYWxo
|
||||
b3N0hwR/AAABMA0GCSqGSIb3DQEBCwUAA4IBAQCICPRCT+m+EKHkaWG2eY2AqQ7a
|
||||
24Bd60ZsZxJNAloXAd1X8cedz5yq0rm9pqF5Fq883dysgCVSDylwqy4YzllhTWsy
|
||||
+M3TE85ZyKKi6S7kR7Z0Exf0I4S7G9zTtrzEXn9kco1q5g/jE7aQi2E2z5poaIg+
|
||||
TlUCq5IePsS6gZCvzXPgU1mJ5dQFlqsOW6Lk1mOCjmKT2SaF4eL2hleatqHv667c
|
||||
fJWYLotjAJoVQKrjItGeHXPosZEW5g17gFD88XZRMlUx5xN5/ioaKmLiyI28aNF3
|
||||
nNaukGC/N5fPsgghb0wkYmPHh+dFE/1uMIq0cOzNcyG6ZQkQzikiYhytWsOu
|
||||
-----END CERTIFICATE-----
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user