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