Initial commit: stateful OpenStack API laboratory simulator.
Ship Keystone auth, multi-service handlers (Yoga→Dalmatian), Compose/Helm packaging, API contract packs, and pytest/Pulumi coverage labs.
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
.git
|
||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
.mypy_cache
|
||||||
|
.pytest_cache
|
||||||
|
.ruff_cache
|
||||||
|
.env
|
||||||
|
htmlcov
|
||||||
|
docs
|
||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
APP_HOST=0.0.0.0
|
||||||
|
# Internal uvicorn port (not published). Public OpenStack ports are on api-gateway.
|
||||||
|
APP_PORT=8080
|
||||||
|
DATABASE_URL=postgresql://openstack:openstack@postgres:5432/openstack_simulator
|
||||||
|
TEST_DATABASE_URL=postgresql://openstack:openstack@postgres:5432/openstack_simulator
|
||||||
|
DB_POOL_MIN_SIZE=1
|
||||||
|
DB_POOL_MAX_SIZE=10
|
||||||
|
DB_CONNECT_TIMEOUT_SECONDS=10
|
||||||
|
DB_COMMAND_TIMEOUT_SECONDS=30
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
REQUEST_ID_HEADER=X-Request-ID
|
||||||
|
OPENSTACK_SERIES=dalmatian
|
||||||
|
# Leave CONTRACT_SNAPSHOT unset for OpenStack-only mode.
|
||||||
|
# CONTRACT_SNAPSHOT=
|
||||||
|
# COMPATIBILITY_EVIDENCE=
|
||||||
|
CONTRACT_FALLBACK=error
|
||||||
|
CATALOG_ARTIFACT_URL_6=stub://openstack/yoga/api-contract
|
||||||
|
CATALOG_ARTIFACT_URL_7=stub://openstack/antelope/api-contract
|
||||||
|
CATALOG_ARTIFACT_URL_8=stub://openstack/caracal/api-contract
|
||||||
|
CATALOG_ARTIFACT_URL_9=stub://openstack/dalmatian/api-contract
|
||||||
|
TICKET_SIGNING_KEY=development-only-signing-key-change-me
|
||||||
|
TASK_WORKER_CONCURRENCY=2
|
||||||
|
TASK_LEASE_SECONDS=30
|
||||||
|
SIMULATION_SEED=42
|
||||||
|
SIMULATION_TIME_SCALE=10
|
||||||
|
SEED_PROFILE=minimal
|
||||||
|
OS_PASSWORD=secret
|
||||||
|
SIMULATOR_ADMIN_ENABLED=false
|
||||||
|
SIMULATOR_ADMIN_TOKEN=replace-with-a-long-random-secret
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
offline:
|
||||||
|
name: Offline quality gate
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Prepare .env
|
||||||
|
run: test -f .env || cp .env.example .env
|
||||||
|
|
||||||
|
- name: Build development image
|
||||||
|
run: docker compose build simulator dev
|
||||||
|
|
||||||
|
- name: Ruff format check
|
||||||
|
run: docker compose run --rm --no-deps dev ruff format --check .
|
||||||
|
|
||||||
|
- name: Ruff lint
|
||||||
|
run: docker compose run --rm --no-deps dev ruff check .
|
||||||
|
|
||||||
|
- name: Mypy
|
||||||
|
run: docker compose run --rm --no-deps dev mypy
|
||||||
|
|
||||||
|
- name: Offline pytest + coverage
|
||||||
|
run: >
|
||||||
|
docker compose run --rm --no-deps dev
|
||||||
|
pytest -m "not integration and not compatibility"
|
||||||
|
--cov=app --cov-report=term-missing --cov-report=xml
|
||||||
+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
|
||||||
|
|
||||||
|
# Lab probe output (regenerated by scripts/probe_api_surface.py)
|
||||||
|
evidence/_api_surface_probe.json
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
# 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/openstack ./contracts/openstack
|
||||||
|
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="openstack-api-simulator" \
|
||||||
|
org.opencontainers.image.version="$APP_VERSION" \
|
||||||
|
org.opencontainers.image.source="https://github.com/inecs/openstack-api-simulator"
|
||||||
|
ENV PATH="/opt/venv/bin:$PATH" \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
APP_HOST=0.0.0.0 \
|
||||||
|
APP_PORT=8080
|
||||||
|
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
|
||||||
|
WORKDIR /app
|
||||||
|
USER 10001:10001
|
||||||
|
# Internal listen only — public OpenStack 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
|
||||||
|
RUN pip install --upgrade "pip>=25.1,<26" && pip install -e '.[dev]'
|
||||||
|
ENTRYPOINT []
|
||||||
|
CMD ["bash"]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
Copyright 2026 openstack-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,213 @@
|
|||||||
|
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 ?= openstack-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/openstack-api-simulator
|
||||||
|
|
||||||
|
.PHONY: help install format lint typecheck test test-unit test-integration test-contract test-compatibility test-surface evidence coverage run dev up down restart logs docker-build docker-up docker-down docker-logs docker-restart db-up db-down db-migrate db-reset api-import api-diff seed seed-demo smoke clean ci ci-all shell release release-build release-up release-down release-seed helm-deps helm-template \
|
||||||
|
test-pulumi-smoke test-pulumi pulumi-tests test-smoke-all-lab test-all-lab 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)
|
||||||
|
|
||||||
|
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 .
|
||||||
|
|
||||||
|
typecheck: ## Run strict mypy checks
|
||||||
|
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) mypy
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
test-integration: ## Run tests that require PostgreSQL
|
||||||
|
@test -f .env || cp .env.example .env
|
||||||
|
$(COMPOSE) up -d postgres
|
||||||
|
$(COMPOSE) run --rm $(SERVICE_DEV) pytest -m integration
|
||||||
|
|
||||||
|
test-contract: ## Run offline API contract tests
|
||||||
|
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) pytest -m contract
|
||||||
|
|
||||||
|
test-compatibility: ## Run OpenStack smoke against the Compose stack
|
||||||
|
@test -f .env || cp .env.example .env
|
||||||
|
$(COMPOSE) up -d --build --wait
|
||||||
|
$(COMPOSE) run --rm --entrypoint python $(SERVICE_SIM) -m app.openstack.seed_cli --profile minimal
|
||||||
|
python3 examples/python/openstack_smoke.py
|
||||||
|
|
||||||
|
test-surface: ## Probe every OpenStack pack operation (Yoga→Dalmatian)
|
||||||
|
@test -f .env || cp .env.example .env
|
||||||
|
$(COMPOSE) up -d --wait
|
||||||
|
$(COMPOSE) run --rm --no-deps --entrypoint python $(SERVICE_SIM) examples/python/openstack_surface_probe.py --host http://api-gateway:5000
|
||||||
|
$(COMPOSE) run --rm --no-deps --entrypoint python $(SERVICE_SIM) -m pytest tests/openstack -q --tb=line
|
||||||
|
|
||||||
|
evidence: ## Regenerate per-major verified surface evidence ledgers
|
||||||
|
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) python -m app.evidence_gen
|
||||||
|
|
||||||
|
coverage: ## Run offline tests with coverage enforcement
|
||||||
|
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) pytest $(PYTEST_OFFLINE) --cov=app --cov-report=term-missing --cov-report=xml
|
||||||
|
|
||||||
|
run: ## Run the application in the foreground
|
||||||
|
@test -f .env || cp .env.example .env
|
||||||
|
$(COMPOSE) up --build
|
||||||
|
|
||||||
|
up: ## Start PostgreSQL, simulator, and TLS 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 from all services
|
||||||
|
$(COMPOSE) logs -f
|
||||||
|
|
||||||
|
dev: ## Run the application with auto-reload in Docker
|
||||||
|
@test -f .env || cp .env.example .env
|
||||||
|
$(COMPOSE) up -d postgres migrate
|
||||||
|
$(COMPOSE) up simulator
|
||||||
|
|
||||||
|
docker-build: ## Build runtime and development images
|
||||||
|
$(MAKE) install
|
||||||
|
|
||||||
|
docker-up: up ## Alias for up
|
||||||
|
|
||||||
|
docker-restart: ## Rebuild and recreate simulator and TLS gateway only
|
||||||
|
$(COMPOSE) up -d --build --force-recreate simulator tls-gateway
|
||||||
|
|
||||||
|
docker-down: down ## Alias for down
|
||||||
|
|
||||||
|
docker-logs: ## Follow simulator logs only
|
||||||
|
$(COMPOSE) logs -f simulator
|
||||||
|
|
||||||
|
db-up: ## Start PostgreSQL only
|
||||||
|
@test -f .env || cp .env.example .env
|
||||||
|
$(COMPOSE) up -d postgres
|
||||||
|
|
||||||
|
db-down: ## Stop PostgreSQL
|
||||||
|
$(COMPOSE) stop postgres
|
||||||
|
|
||||||
|
db-migrate: ## Apply database migrations
|
||||||
|
@test -f .env || cp .env.example .env
|
||||||
|
$(COMPOSE) run --rm migrate
|
||||||
|
|
||||||
|
db-reset: ## Recreate the local database volume
|
||||||
|
$(COMPOSE) down -v
|
||||||
|
$(COMPOSE) up -d postgres
|
||||||
|
$(COMPOSE) run --rm migrate
|
||||||
|
|
||||||
|
api-import: ## Import an API snapshot
|
||||||
|
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) openstack-api-contract import $(ARGS)
|
||||||
|
|
||||||
|
api-diff: ## Compare API snapshots
|
||||||
|
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) openstack-api-contract diff $(ARGS)
|
||||||
|
|
||||||
|
seed: ## Seed minimal OpenStack lab data
|
||||||
|
@test -f .env || cp .env.example .env
|
||||||
|
SEED_PROFILE="$${PROFILE:-minimal}" $(COMPOSE) run --rm --entrypoint python $(SERVICE_SIM) -m app.openstack.seed_cli --profile "$${PROFILE:-minimal}"
|
||||||
|
|
||||||
|
seed-demo: ## Seed full OpenStack demo cloud (~1000 servers)
|
||||||
|
@test -f .env || cp .env.example .env
|
||||||
|
$(COMPOSE) run --rm --entrypoint python $(SERVICE_SIM) -m app.openstack.seed_cli --profile demo
|
||||||
|
|
||||||
|
smoke: ## Keystone → multi-service OpenStack smoke
|
||||||
|
@test -f .env || cp .env.example .env
|
||||||
|
$(COMPOSE) up -d --wait
|
||||||
|
python3 examples/python/openstack_smoke.py
|
||||||
|
|
||||||
|
shell: ## Open an interactive shell in the development container
|
||||||
|
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) bash
|
||||||
|
|
||||||
|
clean: ## Remove generated local artifacts
|
||||||
|
rm -rf .coverage coverage.xml htmlcov .mypy_cache .pytest_cache .ruff_cache
|
||||||
|
|
||||||
|
ci: ## Offline quality gate + full API surface probe (Postgres)
|
||||||
|
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) sh -c '\
|
||||||
|
ruff format --check . && \
|
||||||
|
ruff check . && \
|
||||||
|
mypy && \
|
||||||
|
pytest $(PYTEST_OFFLINE) --cov=app --cov-report=term-missing --cov-report=xml'
|
||||||
|
$(MAKE) test-surface
|
||||||
|
|
||||||
|
ci-all: ## Full CI: offline + surface + remaining integration + client compatibility
|
||||||
|
$(MAKE) ci
|
||||||
|
$(MAKE) test-integration
|
||||||
|
$(MAKE) test-compatibility
|
||||||
|
|
||||||
|
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|demo)
|
||||||
|
SEED_PROFILE="$${PROFILE:-minimal}" IMAGE_TAG="$${IMAGE_TAG:-$(VERSION)}" DOCKER_IMAGE="$(DOCKER_IMAGE)" \
|
||||||
|
$(COMPOSE_RELEASE) run --rm --entrypoint python simulator -m app.openstack.seed_cli --profile "$${PROFILE:-minimal}"
|
||||||
|
|
||||||
|
helm-deps: ## No-op placeholder (chart has no OCI dependencies)
|
||||||
|
@echo "Chart $(HELM_CHART) vendors PostgreSQL templates; no helm dependency update required."
|
||||||
|
|
||||||
|
helm-template: ## Render Helm manifests locally (requires helm)
|
||||||
|
helm template os-sim $(HELM_CHART) \
|
||||||
|
-f $(HELM_CHART)/values-ingress-example.yaml \
|
||||||
|
--set certManager.email=docs@example.com \
|
||||||
|
--set secret.ticketSigningKey=docs-only-signing-key
|
||||||
|
|
||||||
|
# --- API coverage lab (pulumi-tests/, Pulumi only) ---
|
||||||
|
test-pulumi-smoke: ## Pulumi API coverage smoke (all series, collections GET)
|
||||||
|
$(MAKE) -C pulumi-tests test-pulumi-smoke
|
||||||
|
|
||||||
|
test-pulumi: ## Pulumi API coverage full (all series, lifecycle)
|
||||||
|
$(MAKE) -C pulumi-tests test-pulumi
|
||||||
|
|
||||||
|
pulumi-tests: ## Run full pulumi-tests suite (alias)
|
||||||
|
$(MAKE) -C pulumi-tests pulumi-tests
|
||||||
|
|
||||||
|
test-smoke-all-lab: ## Alias → test-pulumi-smoke
|
||||||
|
$(MAKE) -C pulumi-tests test-pulumi-smoke
|
||||||
|
|
||||||
|
test-all-lab: ## Alias → test-pulumi
|
||||||
|
$(MAKE) -C pulumi-tests test-pulumi
|
||||||
|
|
||||||
|
clean-test-resources: ## Reseed demo for lab suites
|
||||||
|
$(MAKE) -C pulumi-tests clean-test-resources
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
**Language / Язык:** [English](README.md) | [Русский](README.ru.md)
|
||||||
|
|
||||||
|
[](https://github.com/inecs/openstack-api-simulator/actions/workflows/ci.yml)
|
||||||
|
|
||||||
|
# openstack-api-simulator
|
||||||
|
|
||||||
|
Stateful laboratory simulator for OpenStack APIs: Keystone auth, a multi-port
|
||||||
|
gateway on OpenStack default ports, specialized
|
||||||
|
Nova/Neutron/Glance/Cinder/Heat/Swift/Ironic/Octavia handlers, and
|
||||||
|
schema-complete coverage for the remaining catalog services (Yoga → Dalmatian).
|
||||||
|
|
||||||
|
## Quick start (Compose)
|
||||||
|
|
||||||
|
### Published image (Docker Hub)
|
||||||
|
|
||||||
|
Clone this repository (api-gateway nginx config and lab TLS files are bind-mounted
|
||||||
|
from `./docker/`), then pull and run the published runtime image — no local app
|
||||||
|
build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/inecs/openstack-api-simulator.git
|
||||||
|
cd openstack-api-simulator
|
||||||
|
|
||||||
|
docker compose -f docker-compose.release.yml up -d --wait
|
||||||
|
# or: make release-up
|
||||||
|
|
||||||
|
curl -sf http://127.0.0.1:5000/health/ready
|
||||||
|
|
||||||
|
# Optional full synthetic cloud (~1000 servers)
|
||||||
|
make release-seed PROFILE=demo
|
||||||
|
```
|
||||||
|
|
||||||
|
Image: [`inecs/openstack-api-simulator`](https://hub.docker.com/r/inecs/openstack-api-simulator)
|
||||||
|
(publish with `make release` when ready).
|
||||||
|
|
||||||
|
### Development checkout
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up -d --build --wait
|
||||||
|
|
||||||
|
# Optional full synthetic cloud (~1000 servers)
|
||||||
|
make seed-demo
|
||||||
|
|
||||||
|
# Full multi-service smoke
|
||||||
|
make smoke
|
||||||
|
```
|
||||||
|
|
||||||
|
- Console: [http://localhost:5000/](http://localhost:5000/)
|
||||||
|
- OpenAPI: [http://localhost:5000/docs](http://localhost:5000/docs)
|
||||||
|
|
||||||
|
More detail: [Getting started](docs/getting-started.md).
|
||||||
|
|
||||||
|
### Helm (Kubernetes + Ingress + Let's Encrypt)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm upgrade --install os-sim ./helm/openstack-api-simulator \
|
||||||
|
-n openstack-sim --create-namespace \
|
||||||
|
-f ./helm/openstack-api-simulator/values-ingress-example.yaml \
|
||||||
|
--set certManager.email=you@example.com \
|
||||||
|
--set ingress.hosts[0].host=os-sim.example.com \
|
||||||
|
--set ingress.tls[0].hosts[0]=os-sim.example.com \
|
||||||
|
--set secret.ticketSigningKey="$(openssl rand -hex 32)" \
|
||||||
|
--set postgresql.auth.password="$(openssl rand -hex 16)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Details: **[Kubernetes / Helm](docs/kubernetes.md)** · chart README:
|
||||||
|
[`helm/openstack-api-simulator`](helm/openstack-api-simulator).
|
||||||
|
|
||||||
|
Minimal ClusterIP + port-forward:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm upgrade --install os-sim ./helm/openstack-api-simulator \
|
||||||
|
-n openstack-sim --create-namespace \
|
||||||
|
--set secret.ticketSigningKey="$(openssl rand -hex 32)" \
|
||||||
|
--set seed.enabled=true --set seed.profile=minimal
|
||||||
|
|
||||||
|
kubectl -n openstack-sim port-forward \
|
||||||
|
svc/os-sim-openstack-api-simulator-gateway \
|
||||||
|
5000:5000 8774:8774 9696:9696 9292:9292 8776:8776
|
||||||
|
```
|
||||||
|
|
||||||
|
### Credentials (seeded)
|
||||||
|
|
||||||
|
**Minimal seed** (default on startup):
|
||||||
|
|
||||||
|
| User | Password | Project | Role |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `admin` | `secret` | `admin` / `demo` | admin |
|
||||||
|
| `demo` | `secret` | `demo` | member |
|
||||||
|
|
||||||
|
**Demo cloud** (Data drawer → *Load demo cloud* / `make seed-demo`): ~1000 servers,
|
||||||
|
16 hypervisors, 3 AZs, 5 projects, networks/ports/FIPs, 600 volumes, LBs, stacks,
|
||||||
|
Ironic, Swift.
|
||||||
|
|
||||||
|
| User | Password | Typical projects |
|
||||||
|
|---|---|---|
|
||||||
|
| `admin` | `secret` | all projects |
|
||||||
|
| `ops` | `secret` | production, staging |
|
||||||
|
| `developer` | `secret` | development, staging |
|
||||||
|
| `demo` / `auditor` | `secret` | demo / production |
|
||||||
|
|
||||||
|
Domain: `Default`.
|
||||||
|
|
||||||
|
### Auth example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -i -X POST http://localhost:5000/v3/auth/tokens \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"auth": {
|
||||||
|
"identity": {
|
||||||
|
"methods": ["password"],
|
||||||
|
"password": {
|
||||||
|
"user": {
|
||||||
|
"name": "demo",
|
||||||
|
"domain": {"name": "Default"},
|
||||||
|
"password": "secret"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"project": {"name": "demo", "domain": {"name": "Default"}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
# Use X-Subject-Token as X-Auth-Token.
|
||||||
|
|
||||||
|
curl -sH "X-Auth-Token: $TOKEN" -H "OpenStack-API-Version: compute 2.79" \
|
||||||
|
http://localhost:8774/v2.1/servers/detail
|
||||||
|
curl -sH "X-Auth-Token: $TOKEN" http://localhost:9696/v2.0/routers
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Documentation is bilingual. Use the **Language / Язык** switcher at the top of
|
||||||
|
each page, or open the Russian root [README.ru.md](README.ru.md). Index:
|
||||||
|
[docs/README.md](docs/README.md) · [docs/ru/README.md](docs/ru/README.md).
|
||||||
|
|
||||||
|
| Guide | Topic |
|
||||||
|
|---|---|
|
||||||
|
| [Getting started](docs/getting-started.md) | First lab session (Compose) |
|
||||||
|
| [Kubernetes / Helm](docs/kubernetes.md) | Cluster install, Ingress, cert-manager |
|
||||||
|
| [Configuration](docs/configuration.md) | Env vars, Compose, Helm knobs |
|
||||||
|
| [Authentication](docs/authentication.md) | Keystone tokens & seeded users |
|
||||||
|
| [Ports](docs/ports.md) | Real OpenStack API ports (1:1 host publish) |
|
||||||
|
| [API surface](docs/api-surface.md) | Specialized vs schema packs |
|
||||||
|
| [API versions](docs/api-versions.md) | Yoga → Dalmatian series |
|
||||||
|
| [API coverage](docs/api_coverage.md) | Generated operation counts |
|
||||||
|
| [Seed profiles](docs/seed-profiles.md) | `minimal` / `demo` |
|
||||||
|
| [Clients](docs/clients.md) | SDK / CLI |
|
||||||
|
| [Web UI](docs/web-ui.md) | Console drawers |
|
||||||
|
| [Operations](docs/operations.md) | Day-2, release, reseed |
|
||||||
|
| [Architecture](docs/architecture.md) | Components & request path |
|
||||||
|
| [Security](docs/security.md) | Lab threat model |
|
||||||
|
| [Observability](docs/observability.md) | Health & logs |
|
||||||
|
| [Troubleshooting](docs/troubleshooting.md) | Common failures |
|
||||||
|
| [FAQ](docs/faq.md) | Short Q&A |
|
||||||
|
| [Domains](docs/domains/README.md) | Per-service notes |
|
||||||
|
| [Examples](docs/examples/overview.md) | Client cookbooks |
|
||||||
|
| [Hypervisor-lab](docs/hypervisor-lab.md) | Pulumi API coverage (all ops × series) |
|
||||||
|
|
||||||
|
## API coverage lab (Pulumi)
|
||||||
|
|
||||||
|
Suite under [`pulumi-tests/`](pulumi-tests/) maximises **`pulumi_openstack`**,
|
||||||
|
then HTTP-probes pack ops with **non-empty** checks for **yoga → dalmatian**.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make pulumi-tests # from repo root
|
||||||
|
# or:
|
||||||
|
cd pulumi-tests && make test-pulumi-smoke && make test-pulumi
|
||||||
|
open pulumi-tests/reports/pulumi-report.html
|
||||||
|
```
|
||||||
|
|
||||||
|
Details: **[docs/hypervisor-lab.md](docs/hypervisor-lab.md)**.
|
||||||
|
|
||||||
|
Full matrix of **real OpenStack default ports** published 1:1 (Keystone `:5000`,
|
||||||
|
Nova `:8774`, Neutron `:9696`, Glance `:9292`, Cinder `:8776`, …): see
|
||||||
|
[docs/ports.md](docs/ports.md).
|
||||||
|
|
||||||
|
Nginx sets `X-OpenStack-Service` / `X-Forwarded-Port`. The app rewrites to
|
||||||
|
`/_os/<service>/…` so `/v3` (Keystone vs Cinder) and `/v1` (Heat vs Swift) do not collide.
|
||||||
|
|
||||||
|
## Implemented API surface
|
||||||
|
|
||||||
|
Contract packs under `contracts/openstack/<series>/` drive **1300+ operations**
|
||||||
|
across **28 services** (Yoga → Dalmatian). The schema engine mounts every pack
|
||||||
|
operation; specialized routers keep stateful happy-paths.
|
||||||
|
|
||||||
|
| Tooling | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `PYTHONPATH=tools python3 tools/os_api_inventory/generate_packs.py` | Regenerate series packs |
|
||||||
|
| `python3 tools/os_api_inventory/coverage_report.py` | Write [docs/api_coverage.md](docs/api_coverage.md) |
|
||||||
|
| `python3 examples/python/openstack_smoke.py` | Multi-port GET smoke |
|
||||||
|
| `python3 examples/python/openstack_surface_probe.py` | Full lifecycle probe |
|
||||||
|
|
||||||
|
**WebUI:** Environment → OpenStack API pack — activate series and microversions.
|
||||||
|
|
||||||
|
This is a **lab surface-complete** simulator (API-ref shaped responses), not
|
||||||
|
bit-identical upstream OpenStack.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Apache-2.0 — see [LICENSE](LICENSE).
|
||||||
+205
@@ -0,0 +1,205 @@
|
|||||||
|
**Language / Язык:** [English](README.md) | [Русский](README.ru.md)
|
||||||
|
|
||||||
|
[](https://github.com/inecs/openstack-api-simulator/actions/workflows/ci.yml)
|
||||||
|
|
||||||
|
# openstack-api-simulator
|
||||||
|
|
||||||
|
Лабораторный stateful-симулятор OpenStack API: аутентификация Keystone, multi-port
|
||||||
|
шлюз на стандартных портах OpenStack, специализированные обработчики
|
||||||
|
Nova/Neutron/Glance/Cinder/Heat/Swift/Ironic/Octavia и schema-complete покрытие
|
||||||
|
остальных сервисов каталога (Yoga → Dalmatian).
|
||||||
|
|
||||||
|
## Быстрый старт (Compose)
|
||||||
|
|
||||||
|
### Опубликованный образ (Docker Hub)
|
||||||
|
|
||||||
|
Клонируйте репозиторий (nginx-конфиг api-gateway и лабораторные TLS-файлы
|
||||||
|
монтируются из `./docker/`), затем скачайте и запустите опубликованный
|
||||||
|
runtime-образ — без локальной сборки приложения:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/inecs/openstack-api-simulator.git
|
||||||
|
cd openstack-api-simulator
|
||||||
|
|
||||||
|
docker compose -f docker-compose.release.yml up -d --wait
|
||||||
|
# или: make release-up
|
||||||
|
|
||||||
|
curl -sf http://127.0.0.1:5000/health/ready
|
||||||
|
|
||||||
|
# Опционально: полное синтетическое облако (~1000 серверов)
|
||||||
|
make release-seed PROFILE=demo
|
||||||
|
```
|
||||||
|
|
||||||
|
Образ: [`inecs/openstack-api-simulator`](https://hub.docker.com/r/inecs/openstack-api-simulator)
|
||||||
|
(публикация: `make release`, когда будете готовы).
|
||||||
|
|
||||||
|
### Development checkout
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up -d --build --wait
|
||||||
|
|
||||||
|
# Опционально: полное синтетическое облако (~1000 серверов)
|
||||||
|
make seed-demo
|
||||||
|
|
||||||
|
# Полный multi-service smoke
|
||||||
|
make smoke
|
||||||
|
```
|
||||||
|
|
||||||
|
- Консоль: [http://localhost:5000/](http://localhost:5000/)
|
||||||
|
- OpenAPI: [http://localhost:5000/docs](http://localhost:5000/docs)
|
||||||
|
|
||||||
|
Подробнее: [Быстрый старт](docs/ru/getting-started.md).
|
||||||
|
|
||||||
|
### Helm (Kubernetes + Ingress + Let's Encrypt)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm upgrade --install os-sim ./helm/openstack-api-simulator \
|
||||||
|
-n openstack-sim --create-namespace \
|
||||||
|
-f ./helm/openstack-api-simulator/values-ingress-example.yaml \
|
||||||
|
--set certManager.email=you@example.com \
|
||||||
|
--set ingress.hosts[0].host=os-sim.example.com \
|
||||||
|
--set ingress.tls[0].hosts[0]=os-sim.example.com \
|
||||||
|
--set secret.ticketSigningKey="$(openssl rand -hex 32)" \
|
||||||
|
--set postgresql.auth.password="$(openssl rand -hex 16)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Подробности: **[Kubernetes / Helm](docs/ru/kubernetes.md)** · README чарта:
|
||||||
|
[`helm/openstack-api-simulator`](helm/openstack-api-simulator/README.ru.md).
|
||||||
|
|
||||||
|
Минимальный ClusterIP + port-forward:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm upgrade --install os-sim ./helm/openstack-api-simulator \
|
||||||
|
-n openstack-sim --create-namespace \
|
||||||
|
--set secret.ticketSigningKey="$(openssl rand -hex 32)" \
|
||||||
|
--set seed.enabled=true --set seed.profile=minimal
|
||||||
|
|
||||||
|
kubectl -n openstack-sim port-forward \
|
||||||
|
svc/os-sim-openstack-api-simulator-gateway \
|
||||||
|
5000:5000 8774:8774 9696:9696 9292:9292 8776:8776
|
||||||
|
```
|
||||||
|
|
||||||
|
### Учётные данные (seed)
|
||||||
|
|
||||||
|
**Minimal seed** (по умолчанию при старте):
|
||||||
|
|
||||||
|
| Пользователь | Пароль | Проект | Роль |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `admin` | `secret` | `admin` / `demo` | admin |
|
||||||
|
| `demo` | `secret` | `demo` | member |
|
||||||
|
|
||||||
|
**Demo cloud** (Data drawer → *Load demo cloud* / `make seed-demo`): ~1000 серверов,
|
||||||
|
16 гипервизоров, 3 AZ, 5 проектов, сети/порты/FIP, 600 томов, LB, стеки,
|
||||||
|
Ironic, Swift.
|
||||||
|
|
||||||
|
| Пользователь | Пароль | Типичные проекты |
|
||||||
|
|---|---|---|
|
||||||
|
| `admin` | `secret` | все проекты |
|
||||||
|
| `ops` | `secret` | production, staging |
|
||||||
|
| `developer` | `secret` | development, staging |
|
||||||
|
| `demo` / `auditor` | `secret` | demo / production |
|
||||||
|
|
||||||
|
Домен: `Default`.
|
||||||
|
|
||||||
|
### Пример аутентификации
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -i -X POST http://localhost:5000/v3/auth/tokens \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"auth": {
|
||||||
|
"identity": {
|
||||||
|
"methods": ["password"],
|
||||||
|
"password": {
|
||||||
|
"user": {
|
||||||
|
"name": "demo",
|
||||||
|
"domain": {"name": "Default"},
|
||||||
|
"password": "secret"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"project": {"name": "demo", "domain": {"name": "Default"}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
# Используйте X-Subject-Token как X-Auth-Token.
|
||||||
|
|
||||||
|
curl -sH "X-Auth-Token: $TOKEN" -H "OpenStack-API-Version: compute 2.79" \
|
||||||
|
http://localhost:8774/v2.1/servers/detail
|
||||||
|
curl -sH "X-Auth-Token: $TOKEN" http://localhost:9696/v2.0/routers
|
||||||
|
```
|
||||||
|
|
||||||
|
## Документация
|
||||||
|
|
||||||
|
Документация двуязычная. Переключатель **Language / Язык** — в первой строке
|
||||||
|
каждой страницы; английский корень — [README.md](README.md). Оглавление:
|
||||||
|
[docs/README.md](docs/README.md) · [docs/ru/README.md](docs/ru/README.md).
|
||||||
|
|
||||||
|
| Руководство | Тема |
|
||||||
|
|---|---|
|
||||||
|
| [Быстрый старт](docs/ru/getting-started.md) | Первая лабораторная сессия (Compose) |
|
||||||
|
| [Kubernetes / Helm](docs/ru/kubernetes.md) | Установка в кластер, Ingress, cert-manager |
|
||||||
|
| [Конфигурация](docs/ru/configuration.md) | Переменные окружения, Compose, Helm |
|
||||||
|
| [Аутентификация](docs/ru/authentication.md) | Токены Keystone и seed-пользователи |
|
||||||
|
| [Порты](docs/ru/ports.md) | Реальные порты API OpenStack (публикация 1:1) |
|
||||||
|
| [API surface](docs/ru/api-surface.md) | Специализированные vs schema-пакеты |
|
||||||
|
| [Версии API](docs/ru/api-versions.md) | Серии Yoga → Dalmatian |
|
||||||
|
| [Покрытие API](docs/ru/api_coverage.md) | Счётчики операций |
|
||||||
|
| [Seed-профили](docs/ru/seed-profiles.md) | `minimal` / `demo` |
|
||||||
|
| [Клиенты](docs/ru/clients.md) | SDK / CLI |
|
||||||
|
| [Web UI](docs/ru/web-ui.md) | Консоль и drawers |
|
||||||
|
| [Эксплуатация](docs/ru/operations.md) | Day-2, релиз, reseed |
|
||||||
|
| [Архитектура](docs/ru/architecture.md) | Компоненты и путь запроса |
|
||||||
|
| [Безопасность](docs/ru/security.md) | Threat model лаборатории |
|
||||||
|
| [Наблюдаемость](docs/ru/observability.md) | Health и логи |
|
||||||
|
| [Устранение неполадок](docs/ru/troubleshooting.md) | Типичные сбои |
|
||||||
|
| [FAQ](docs/ru/faq.md) | Краткие ответы |
|
||||||
|
| [Домены](docs/ru/domains/README.md) | Заметки по сервисам |
|
||||||
|
| [Примеры](docs/ru/examples/overview.md) | Cookbook'и клиентов |
|
||||||
|
| [Hypervisor-lab](docs/ru/hypervisor-lab.md) | Pulumi-покрытие API (все ops × серии) |
|
||||||
|
|
||||||
|
## Лаборатория покрытия API (Pulumi)
|
||||||
|
|
||||||
|
Сьют в [`pulumi-tests/`](pulumi-tests/) максимально использует **`pulumi_openstack`**,
|
||||||
|
затем HTTP-probe pack-операций с проверкой **непустых** тел для **yoga → dalmatian**.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make pulumi-tests # из корня репозитория
|
||||||
|
# или:
|
||||||
|
cd pulumi-tests && make test-pulumi-smoke && make test-pulumi
|
||||||
|
open pulumi-tests/reports/pulumi-report.html
|
||||||
|
```
|
||||||
|
|
||||||
|
Подробности: **[docs/ru/hypervisor-lab.md](docs/ru/hypervisor-lab.md)**.
|
||||||
|
|
||||||
|
Полная матрица **реальных портов OpenStack по умолчанию**, публикуемых 1:1
|
||||||
|
(Keystone `:5000`, Nova `:8774`, Neutron `:9696`, Glance `:9292`, Cinder `:8776`, …):
|
||||||
|
см. [docs/ru/ports.md](docs/ru/ports.md).
|
||||||
|
|
||||||
|
Nginx выставляет `X-OpenStack-Service` / `X-Forwarded-Port`. Приложение
|
||||||
|
переписывает путь в `/_os/<service>/…`, чтобы `/v3` (Keystone vs Cinder) и `/v1`
|
||||||
|
(Heat vs Swift) не конфликтовали.
|
||||||
|
|
||||||
|
## Реализованная поверхность API
|
||||||
|
|
||||||
|
Пакеты контрактов в `contracts/openstack/<series>/` дают **1300+ операций**
|
||||||
|
по **28 сервисам** (Yoga → Dalmatian). Schema-движок монтирует каждую операцию
|
||||||
|
пакета; специализированные роутеры сохраняют stateful happy-path'ы.
|
||||||
|
|
||||||
|
| Инструмент | Назначение |
|
||||||
|
|---|---|
|
||||||
|
| `PYTHONPATH=tools python3 tools/os_api_inventory/generate_packs.py` | Перегенерация series-пакетов |
|
||||||
|
| `python3 tools/os_api_inventory/coverage_report.py` | Запись [docs/api_coverage.md](docs/api_coverage.md) |
|
||||||
|
| `python3 examples/python/openstack_smoke.py` | Multi-port GET smoke |
|
||||||
|
| `python3 examples/python/openstack_surface_probe.py` | Полный lifecycle-probe |
|
||||||
|
|
||||||
|
**WebUI:** Environment → OpenStack API pack — активация серии и microversions.
|
||||||
|
|
||||||
|
Это **лабораторный surface-complete** симулятор (ответы в форме API-ref), а не
|
||||||
|
бит-в-бит идентичный upstream OpenStack.
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
Apache-2.0 — см. [LICENSE](LICENSE).
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""OpenStack 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 Proxmox-compatible 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,182 @@
|
|||||||
|
"""OpenAPI tag metadata for the OpenStack simulator.
|
||||||
|
|
||||||
|
Legacy Proxmox path→tag helpers remain for optional ``CONTRACT_SNAPSHOT`` mode;
|
||||||
|
they are not pre-declared in Swagger (see ``openapi_tag_metadata``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
_NODE_SECTION_LABELS: dict[str, str] = {
|
||||||
|
"qemu": "QEMU",
|
||||||
|
"lxc": "LXC",
|
||||||
|
"ceph": "Ceph",
|
||||||
|
"storage": "Storage",
|
||||||
|
"sdn": "SDN",
|
||||||
|
"firewall": "Firewall",
|
||||||
|
"apt": "APT",
|
||||||
|
"certificates": "Certificates",
|
||||||
|
"scan": "Scan",
|
||||||
|
"network": "Network",
|
||||||
|
"services": "Services",
|
||||||
|
"capabilities": "Capabilities",
|
||||||
|
"hardware": "Hardware",
|
||||||
|
"replication": "Replication",
|
||||||
|
"tasks": "Tasks",
|
||||||
|
"subscription": "Subscription",
|
||||||
|
"vzdump": "Backup",
|
||||||
|
"disks": "Disks",
|
||||||
|
"config": "Config",
|
||||||
|
"dns": "DNS",
|
||||||
|
"hosts": "Hosts",
|
||||||
|
"status": "Status",
|
||||||
|
"time": "Time",
|
||||||
|
"aplinfo": "Appliance",
|
||||||
|
}
|
||||||
|
|
||||||
|
_CLUSTER_SECTION_LABELS: dict[str, str] = {
|
||||||
|
"sdn": "SDN",
|
||||||
|
"firewall": "Firewall",
|
||||||
|
"notifications": "Notifications",
|
||||||
|
"ha": "HA",
|
||||||
|
"mapping": "Mapping",
|
||||||
|
"acme": "ACME",
|
||||||
|
"config": "Config",
|
||||||
|
"ceph": "Ceph",
|
||||||
|
"jobs": "Jobs",
|
||||||
|
"metrics": "Metrics",
|
||||||
|
"qemu": "QEMU",
|
||||||
|
"backup": "Backup",
|
||||||
|
"bulk-action": "Bulk Action",
|
||||||
|
"replication": "Replication",
|
||||||
|
"backup-info": "Backup Info",
|
||||||
|
"options": "Options",
|
||||||
|
"log": "Log",
|
||||||
|
"nextid": "Next ID",
|
||||||
|
"resources": "Resources",
|
||||||
|
"status": "Status",
|
||||||
|
"tasks": "Tasks",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Friendly Swagger tag names for OpenStack service packs.
|
||||||
|
_SERVICE_TAG_LABELS: dict[str, str] = {
|
||||||
|
"keystone": "Keystone",
|
||||||
|
"nova": "Nova",
|
||||||
|
"neutron": "Neutron",
|
||||||
|
"glance": "Glance",
|
||||||
|
"cinder": "Cinder",
|
||||||
|
"placement": "Placement",
|
||||||
|
"heat": "Heat",
|
||||||
|
"heat-cfn": "Heat CFN",
|
||||||
|
"swift": "Swift",
|
||||||
|
"ironic": "Ironic",
|
||||||
|
"octavia": "Octavia",
|
||||||
|
"barbican": "Barbican",
|
||||||
|
"manila": "Manila",
|
||||||
|
"designate": "Designate",
|
||||||
|
"magnum": "Magnum",
|
||||||
|
"zun": "Zun",
|
||||||
|
"trove": "Trove",
|
||||||
|
"mistral": "Mistral",
|
||||||
|
"aodh": "Aodh",
|
||||||
|
"freezer": "Freezer",
|
||||||
|
"blazar": "Blazar",
|
||||||
|
"vitrage": "Vitrage",
|
||||||
|
"masakari": "Masakari",
|
||||||
|
"tacker": "Tacker",
|
||||||
|
"adjutant": "Adjutant",
|
||||||
|
"cloudkitty": "CloudKitty",
|
||||||
|
"watcher": "Watcher",
|
||||||
|
"zaqar": "Zaqar",
|
||||||
|
}
|
||||||
|
|
||||||
|
_SERVICE_TAG_DESCRIPTIONS: dict[str, str] = {
|
||||||
|
"OpenStack": "Root discovery and service catalog helpers.",
|
||||||
|
"Keystone": "Identity API v3 — auth, projects, users, roles, and domains.",
|
||||||
|
"Nova": "Compute API — servers, flavors, keypairs, and related actions.",
|
||||||
|
"Neutron": "Networking API — networks, subnets, ports, routers, and security groups.",
|
||||||
|
"Glance": "Image API — images and image members.",
|
||||||
|
"Cinder": "Block Storage API — volumes, snapshots, and types.",
|
||||||
|
"Placement": "Placement API — resource providers and inventories.",
|
||||||
|
"Heat": "Orchestration API — stacks and resources.",
|
||||||
|
"Heat CFN": "CloudFormation-compatible Heat API.",
|
||||||
|
"Swift": "Object Storage API — accounts, containers, and objects.",
|
||||||
|
"Ironic": "Bare Metal API — nodes and ports.",
|
||||||
|
"Octavia": "Load Balancer API — load balancers, listeners, and pools.",
|
||||||
|
"Barbican": "Key Manager API — secrets and containers.",
|
||||||
|
"Manila": "Shared File Systems API.",
|
||||||
|
"Designate": "DNS-as-a-Service API.",
|
||||||
|
"Magnum": "Container Infrastructure Management API.",
|
||||||
|
"Zun": "Containers API.",
|
||||||
|
"Trove": "Database-as-a-Service API.",
|
||||||
|
"Mistral": "Workflow API.",
|
||||||
|
"Aodh": "Alarming API.",
|
||||||
|
"Freezer": "Backup API.",
|
||||||
|
"Blazar": "Reservation API.",
|
||||||
|
"Vitrage": "Root Cause Analysis API.",
|
||||||
|
"Masakari": "Instance High Availability API.",
|
||||||
|
"Tacker": "NFV Orchestration API.",
|
||||||
|
"Adjutant": "Admin Automation API.",
|
||||||
|
"CloudKitty": "Rating API.",
|
||||||
|
"Watcher": "Infrastructure Optimization API.",
|
||||||
|
"Zaqar": "Messaging API.",
|
||||||
|
"Simulator": "Health checks, catalog UI, and simulator administration.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def service_openapi_tag(service: str) -> str:
|
||||||
|
"""Swagger tag for an OpenStack service pack (matches specialized router tags)."""
|
||||||
|
|
||||||
|
key = (service or "").strip().lower()
|
||||||
|
if key in _SERVICE_TAG_LABELS:
|
||||||
|
return _SERVICE_TAG_LABELS[key]
|
||||||
|
return key.replace("-", " ").title() or "OpenStack"
|
||||||
|
|
||||||
|
|
||||||
|
def contract_openapi_tag(path: str) -> str:
|
||||||
|
"""Map a semantic contract path to a category (legacy Proxmox contracts)."""
|
||||||
|
|
||||||
|
parts = [part for part in path.strip("/").split("/") if part]
|
||||||
|
if not parts or parts == ["version"]:
|
||||||
|
return "Core"
|
||||||
|
root = parts[0]
|
||||||
|
if root == "access":
|
||||||
|
return "Access"
|
||||||
|
if root == "nodes":
|
||||||
|
if len(parts) >= 3 and parts[1] == "{node}":
|
||||||
|
section = parts[2]
|
||||||
|
label = _NODE_SECTION_LABELS.get(section, section.replace("-", " ").title())
|
||||||
|
return f"Nodes · {label}"
|
||||||
|
return "Nodes"
|
||||||
|
if root == "cluster":
|
||||||
|
if len(parts) >= 2:
|
||||||
|
section = parts[1]
|
||||||
|
label = _CLUSTER_SECTION_LABELS.get(section, section.replace("-", " ").title())
|
||||||
|
return f"Cluster · {label}"
|
||||||
|
return "Cluster"
|
||||||
|
if root == "storage":
|
||||||
|
return "Storage"
|
||||||
|
if root == "pools":
|
||||||
|
return "Pools"
|
||||||
|
return root.replace("-", " ").title()
|
||||||
|
|
||||||
|
|
||||||
|
def contract_openapi_tags(path: str, renderer: str) -> list[str]:
|
||||||
|
"""Return OpenAPI tags for a legacy Proxmox contract route."""
|
||||||
|
|
||||||
|
renderer_label = "API2 JSON" if renderer == "json" else "API2 ExtJS"
|
||||||
|
return [contract_openapi_tag(path), renderer_label]
|
||||||
|
|
||||||
|
|
||||||
|
def openapi_tag_metadata() -> list[dict[str, str]]:
|
||||||
|
"""Descriptions shown in Swagger UI for each OpenStack tag group."""
|
||||||
|
|
||||||
|
from app.openstack.surface import SERVICES
|
||||||
|
|
||||||
|
descriptions = dict(_SERVICE_TAG_DESCRIPTIONS)
|
||||||
|
for spec in SERVICES:
|
||||||
|
tag = service_openapi_tag(spec.name)
|
||||||
|
descriptions.setdefault(
|
||||||
|
tag,
|
||||||
|
f"{spec.typ.title()} API ({spec.name}) on port {spec.port}.",
|
||||||
|
)
|
||||||
|
return [{"name": name, "description": text} for name, text in sorted(descriptions.items())]
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
"""Contract-driven dynamic route and semantic handler registry."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Literal, cast
|
||||||
|
from urllib.parse import parse_qsl
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from app.api.errors import ApiError, ContractValidationError
|
||||||
|
from app.api.openapi import contract_openapi_tags
|
||||||
|
from app.config import Settings
|
||||||
|
from app.contracts.examples import schema_example
|
||||||
|
from app.contracts.model import Method, Schema, Snapshot
|
||||||
|
from app.db.pool import AsyncpgDatabase
|
||||||
|
from app.security.acl import AclEntry, CapabilityRequirement, authorize, requirement_from_contract
|
||||||
|
from app.security.auth import parse_api_token, verify_csrf, verify_secret, verify_ticket
|
||||||
|
|
||||||
|
Handler = Callable[[Request, dict[str, Any]], Awaitable[Any]]
|
||||||
|
FallbackMode = Literal["error", "schema-default", "fixture"]
|
||||||
|
|
||||||
|
|
||||||
|
class RouteCollisionError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class HandlerRegistry:
|
||||||
|
_handlers: dict[tuple[str, str], Handler] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def register(self, path: str, verb: str, handler: Handler) -> None:
|
||||||
|
key = (path, verb.upper())
|
||||||
|
if key in self._handlers:
|
||||||
|
raise RouteCollisionError(f"duplicate semantic handler: {verb} {path}")
|
||||||
|
self._handlers[key] = handler
|
||||||
|
|
||||||
|
def get(self, path: str, verb: str) -> Handler | None:
|
||||||
|
return self._handlers.get((path, verb.upper()))
|
||||||
|
|
||||||
|
def keys(self) -> frozenset[tuple[str, str]]:
|
||||||
|
return frozenset(self._handlers)
|
||||||
|
|
||||||
|
|
||||||
|
def register_contract_routes(
|
||||||
|
app: FastAPI,
|
||||||
|
snapshot: Snapshot,
|
||||||
|
handlers: HandlerRegistry,
|
||||||
|
fallback: FallbackMode = "error",
|
||||||
|
*,
|
||||||
|
existing: set[tuple[str, str, str]] | None = None,
|
||||||
|
require_handler: bool = False,
|
||||||
|
allow_existing: bool = False,
|
||||||
|
) -> set[tuple[str, str, str]]:
|
||||||
|
"""Register `/api2/{json,extjs}` routes for a contract snapshot.
|
||||||
|
|
||||||
|
When ``allow_existing`` is true, path/verb pairs already present in
|
||||||
|
``existing`` are skipped (used to merge older majors onto a primary contract).
|
||||||
|
When ``require_handler`` is true, only methods with a registered semantic
|
||||||
|
handler are added — used for legacy-path aliases.
|
||||||
|
"""
|
||||||
|
|
||||||
|
seen = existing if existing is not None else set()
|
||||||
|
for contract_path in snapshot.paths:
|
||||||
|
for contract_method in contract_path.methods:
|
||||||
|
if require_handler and handlers.get(contract_path.path, contract_method.verb) is None:
|
||||||
|
continue
|
||||||
|
for renderer in ("json", "extjs"):
|
||||||
|
route = f"/api2/{renderer}{contract_path.path}"
|
||||||
|
key = (route, contract_method.verb, renderer)
|
||||||
|
if key in seen:
|
||||||
|
if allow_existing:
|
||||||
|
continue
|
||||||
|
raise RouteCollisionError(
|
||||||
|
f"duplicate contract route: {contract_method.verb} {route}"
|
||||||
|
)
|
||||||
|
seen.add(key)
|
||||||
|
implemented = handlers.get(contract_path.path, contract_method.verb) is not None
|
||||||
|
endpoint = _endpoint(
|
||||||
|
contract_path.path,
|
||||||
|
contract_method,
|
||||||
|
renderer,
|
||||||
|
handlers,
|
||||||
|
fallback,
|
||||||
|
)
|
||||||
|
app.add_api_route(
|
||||||
|
route,
|
||||||
|
endpoint,
|
||||||
|
methods=[contract_method.verb],
|
||||||
|
name=f"contract:{renderer}:{contract_method.verb}:{contract_path.path}",
|
||||||
|
tags=cast(
|
||||||
|
list[str | Enum], contract_openapi_tags(contract_path.path, renderer)
|
||||||
|
),
|
||||||
|
openapi_extra={
|
||||||
|
"x-proxmox-method-checksum": contract_method.checksum,
|
||||||
|
"x-proxmox-implementation": "implemented" if implemented else "unsupported",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
def register_legacy_handler_routes(
|
||||||
|
app: FastAPI,
|
||||||
|
handlers: HandlerRegistry,
|
||||||
|
store_root: Path,
|
||||||
|
fallback: FallbackMode = "error",
|
||||||
|
*,
|
||||||
|
primary_version: str | None = None,
|
||||||
|
existing: set[tuple[str, str, str]] | None = None,
|
||||||
|
) -> set[tuple[str, str, str]]:
|
||||||
|
"""Expose handler-backed paths declared only in older cached contracts."""
|
||||||
|
|
||||||
|
seen = existing if existing is not None else set()
|
||||||
|
if not store_root.is_dir():
|
||||||
|
return seen
|
||||||
|
for revision_dir in sorted(store_root.iterdir()):
|
||||||
|
snapshot_path = revision_dir / "snapshot.json"
|
||||||
|
if not snapshot_path.is_file():
|
||||||
|
continue
|
||||||
|
snapshot = Snapshot.model_validate_json(snapshot_path.read_bytes())
|
||||||
|
if primary_version and snapshot.source_version == primary_version:
|
||||||
|
continue
|
||||||
|
seen = register_contract_routes(
|
||||||
|
app,
|
||||||
|
snapshot,
|
||||||
|
handlers,
|
||||||
|
fallback,
|
||||||
|
existing=seen,
|
||||||
|
require_handler=True,
|
||||||
|
allow_existing=True,
|
||||||
|
)
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
def _endpoint(
|
||||||
|
semantic_path: str,
|
||||||
|
method: Method,
|
||||||
|
renderer: str,
|
||||||
|
handlers: HandlerRegistry,
|
||||||
|
fallback: FallbackMode,
|
||||||
|
) -> Callable[[Request], Awaitable[JSONResponse]]:
|
||||||
|
async def dispatch(request: Request) -> JSONResponse:
|
||||||
|
inputs = await _parse_inputs(request, method)
|
||||||
|
await _authenticate(request, semantic_path, method, inputs)
|
||||||
|
handler = handlers.get(semantic_path, method.verb)
|
||||||
|
if handler is not None:
|
||||||
|
data = await handler(request, inputs)
|
||||||
|
elif fallback == "schema-default":
|
||||||
|
data = schema_example(method.returns)
|
||||||
|
elif fallback == "fixture" and "fixture" in method.extra:
|
||||||
|
data = method.extra["fixture"]
|
||||||
|
else:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=501,
|
||||||
|
content={"data": None, "errors": "handler pending for this contract method"},
|
||||||
|
)
|
||||||
|
content = {"data": data, "success": True} if renderer == "extjs" else {"data": data}
|
||||||
|
response = JSONResponse(content)
|
||||||
|
if semantic_path == "/access/ticket" and isinstance(data, dict):
|
||||||
|
ticket = data.get("ticket")
|
||||||
|
if isinstance(ticket, str):
|
||||||
|
response.set_cookie(
|
||||||
|
"PVEAuthCookie", ticket, httponly=True, samesite="strict", path="/"
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
return dispatch
|
||||||
|
|
||||||
|
|
||||||
|
async def _authenticate(
|
||||||
|
request: Request, semantic_path: str, method: Method, inputs: dict[str, Any]
|
||||||
|
) -> None:
|
||||||
|
if semantic_path in {"/version", "/access/ticket"}:
|
||||||
|
return
|
||||||
|
authorization = request.headers.get("Authorization", "")
|
||||||
|
token_privileges: frozenset[str] | None = None
|
||||||
|
principal: str
|
||||||
|
if authorization.startswith("PVEAPIToken="):
|
||||||
|
database = cast(AsyncpgDatabase, request.app.state.database)
|
||||||
|
try:
|
||||||
|
parsed_token = parse_api_token(authorization)
|
||||||
|
except ValueError as error:
|
||||||
|
raise ApiError(401, "authentication failure") from error
|
||||||
|
row = await database.pool.fetchrow(
|
||||||
|
"""SELECT p.name, t.secret_hash, t.privileges, t.privilege_separation
|
||||||
|
FROM api_tokens t JOIN principals p ON p.id=t.principal_id
|
||||||
|
WHERE p.name=$1 AND t.token_id=$2
|
||||||
|
AND (t.expires_at IS NULL OR t.expires_at > now())""",
|
||||||
|
parsed_token.principal,
|
||||||
|
parsed_token.token_id,
|
||||||
|
)
|
||||||
|
if row is None or not verify_secret(parsed_token.secret, str(row["secret_hash"])):
|
||||||
|
raise ApiError(401, "authentication failure")
|
||||||
|
principal = str(row["name"])
|
||||||
|
token_privileges = (
|
||||||
|
frozenset(str(item) for item in row["privileges"])
|
||||||
|
if bool(row["privilege_separation"])
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ticket = request.cookies.get("PVEAuthCookie")
|
||||||
|
if ticket is None:
|
||||||
|
raise ApiError(401, "authentication required")
|
||||||
|
settings = cast(Settings, request.app.state.settings)
|
||||||
|
key = settings.ticket_signing_key.get_secret_value().encode()
|
||||||
|
try:
|
||||||
|
claims = verify_ticket(ticket, key)
|
||||||
|
except ValueError as error:
|
||||||
|
raise ApiError(401, "authentication failure") from error
|
||||||
|
principal = claims.principal
|
||||||
|
if request.method not in {"GET", "HEAD", "OPTIONS"}:
|
||||||
|
csrf_value = request.headers.get("CSRFPreventionToken", "")
|
||||||
|
if not verify_csrf(ticket, csrf_value, key):
|
||||||
|
raise ApiError(403, "invalid CSRF prevention token")
|
||||||
|
request.state.principal = principal
|
||||||
|
if principal == "root@pam" and token_privileges is None:
|
||||||
|
return
|
||||||
|
database = cast(AsyncpgDatabase, request.app.state.database)
|
||||||
|
await _authorize(database, principal, token_privileges, semantic_path, method, inputs)
|
||||||
|
|
||||||
|
|
||||||
|
async def _authorize(
|
||||||
|
database: AsyncpgDatabase,
|
||||||
|
principal: str,
|
||||||
|
token_privileges: frozenset[str] | None,
|
||||||
|
semantic_path: str,
|
||||||
|
method: Method,
|
||||||
|
inputs: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
values = cast(dict[str, Any], inputs["values"])
|
||||||
|
requirement = requirement_from_contract(
|
||||||
|
method.permissions, {name: str(value) for name, value in values.items()}
|
||||||
|
)
|
||||||
|
if requirement is None and semantic_path == "/nodes/{node}/qemu" and method.verb == "POST":
|
||||||
|
requirement = CapabilityRequirement(f"/vms/{values['vmid']}", frozenset({"VM.Allocate"}))
|
||||||
|
if requirement is None and semantic_path == "/nodes/{node}/lxc" and method.verb == "POST":
|
||||||
|
requirement = CapabilityRequirement(f"/vms/{values['vmid']}", frozenset({"VM.Allocate"}))
|
||||||
|
if requirement is None:
|
||||||
|
return
|
||||||
|
rows = await database.pool.fetch(
|
||||||
|
"""SELECT a.path, a.propagate, r.privileges
|
||||||
|
FROM acl_entries a JOIN roles r ON r.name=a.role_name
|
||||||
|
JOIN principals p ON p.id=a.principal_id WHERE p.name=$1
|
||||||
|
UNION ALL
|
||||||
|
SELECT a.path, a.propagate, r.privileges
|
||||||
|
FROM group_acl_entries a JOIN roles r ON r.name=a.role_name
|
||||||
|
JOIN identity_group_members m ON m.group_id=a.group_id
|
||||||
|
JOIN principals p ON p.id=m.principal_id WHERE p.name=$1""",
|
||||||
|
principal,
|
||||||
|
)
|
||||||
|
entries = tuple(
|
||||||
|
AclEntry(
|
||||||
|
principal,
|
||||||
|
str(row["path"]),
|
||||||
|
frozenset(str(item) for item in row["privileges"]),
|
||||||
|
bool(row["propagate"]),
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
)
|
||||||
|
if not authorize(
|
||||||
|
principal,
|
||||||
|
requirement.path,
|
||||||
|
requirement.privileges,
|
||||||
|
entries,
|
||||||
|
token_privileges=token_privileges,
|
||||||
|
require_all=requirement.require_all,
|
||||||
|
):
|
||||||
|
raise ApiError(403, "permission check failed")
|
||||||
|
|
||||||
|
|
||||||
|
async def _parse_inputs(request: Request, method: Method) -> dict[str, Any]:
|
||||||
|
supplied: dict[str, Any] = dict(request.query_params)
|
||||||
|
supplied.update(request.path_params)
|
||||||
|
if request.method not in {"GET", "DELETE"}:
|
||||||
|
content_type = request.headers.get("content-type", "").split(";", 1)[0].strip()
|
||||||
|
if content_type == "application/json":
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ContractValidationError({"body": "invalid JSON"}) from exc
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
raise ContractValidationError({"body": "expected an object"})
|
||||||
|
supplied.update(body)
|
||||||
|
elif content_type == "application/x-www-form-urlencoded":
|
||||||
|
supplied.update(dict(parse_qsl((await request.body()).decode())))
|
||||||
|
|
||||||
|
definitions = {parameter.name: parameter.definition for parameter in method.parameters}
|
||||||
|
indexed = {
|
||||||
|
re.compile("^" + re.escape(name).replace(r"\[n\]", r"\d+") + "$"): definition
|
||||||
|
for name, definition in definitions.items()
|
||||||
|
if "[n]" in name
|
||||||
|
}
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
parsed: dict[str, Any] = {}
|
||||||
|
for name, definition in definitions.items():
|
||||||
|
if "[n]" in name:
|
||||||
|
continue
|
||||||
|
if name not in supplied:
|
||||||
|
if definition.optional:
|
||||||
|
if definition.default is not None:
|
||||||
|
parsed[name] = definition.default
|
||||||
|
continue
|
||||||
|
errors[name] = "property is missing and it is not optional"
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parsed[name] = _coerce(supplied[name], definition)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
errors[name] = str(exc)
|
||||||
|
indexed_names: set[str] = set()
|
||||||
|
for name in supplied.keys() - definitions.keys():
|
||||||
|
indexed_definition = next(
|
||||||
|
(candidate for pattern, candidate in indexed.items() if pattern.fullmatch(name)), None
|
||||||
|
)
|
||||||
|
if indexed_definition is not None:
|
||||||
|
indexed_names.add(name)
|
||||||
|
try:
|
||||||
|
parsed[name] = _coerce(supplied[name], indexed_definition)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
errors[name] = str(exc)
|
||||||
|
for name in supplied.keys() - definitions.keys() - indexed_names:
|
||||||
|
if name not in request.path_params:
|
||||||
|
errors[name] = "property is not defined in schema"
|
||||||
|
if errors:
|
||||||
|
raise ContractValidationError(dict(sorted(errors.items())))
|
||||||
|
# Path params are always available to handlers even when omitted from the
|
||||||
|
# method property schema (common for Proxmox nested resources).
|
||||||
|
for name, value in request.path_params.items():
|
||||||
|
parsed.setdefault(name, value)
|
||||||
|
return {
|
||||||
|
"values": parsed,
|
||||||
|
"path": dict(request.path_params),
|
||||||
|
"provided": tuple(sorted(supplied)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce(value: Any, schema: Schema) -> Any:
|
||||||
|
if schema.type == "integer":
|
||||||
|
parsed: Any = int(value)
|
||||||
|
elif schema.type == "number":
|
||||||
|
parsed = float(value)
|
||||||
|
elif schema.type == "boolean":
|
||||||
|
if isinstance(value, bool):
|
||||||
|
parsed = value
|
||||||
|
elif str(value).lower() in {"1", "true", "yes", "on"}:
|
||||||
|
parsed = True
|
||||||
|
elif str(value).lower() in {"0", "false", "no", "off"}:
|
||||||
|
parsed = False
|
||||||
|
else:
|
||||||
|
raise ValueError("expected a boolean")
|
||||||
|
elif schema.type == "string" or schema.type is None:
|
||||||
|
parsed = str(value)
|
||||||
|
else:
|
||||||
|
parsed = value
|
||||||
|
if schema.enum and parsed not in schema.enum:
|
||||||
|
raise ValueError("value is not in the allowed enumeration")
|
||||||
|
if isinstance(parsed, int | float):
|
||||||
|
if schema.minimum is not None and parsed < schema.minimum:
|
||||||
|
raise ValueError(f"value must be at least {schema.minimum}")
|
||||||
|
if schema.maximum is not None and parsed > schema.maximum:
|
||||||
|
raise ValueError(f"value must be at most {schema.maximum}")
|
||||||
|
if isinstance(parsed, str):
|
||||||
|
if schema.min_length is not None and len(parsed) < schema.min_length:
|
||||||
|
raise ValueError(f"value is shorter than {schema.min_length}")
|
||||||
|
if schema.max_length is not None and len(parsed) > schema.max_length:
|
||||||
|
raise ValueError(f"value is longer than {schema.max_length}")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _schema_default(schema: Schema) -> Any:
|
||||||
|
return schema_example(schema)
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
"""Evidence-based compatibility accounting and reporting."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import StrEnum
|
||||||
|
from html import escape
|
||||||
|
from pathlib import Path
|
||||||
|
from types import MappingProxyType
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||||
|
|
||||||
|
from app.contracts.model import Snapshot
|
||||||
|
|
||||||
|
MethodKey = tuple[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
class CompatibilityDimension(StrEnum):
|
||||||
|
ROUTE_METHOD = "route_method"
|
||||||
|
INPUT_PARAMETERS = "input_parameters"
|
||||||
|
PARAMETER_REQUIREDNESS = "parameter_requiredness"
|
||||||
|
TYPES_CONSTRAINTS = "types_constraints"
|
||||||
|
HTTP_STATUS = "http_status"
|
||||||
|
JSON_STRUCTURE = "json_structure"
|
||||||
|
RESPONSE_FIELD_TYPES = "response_field_types"
|
||||||
|
RESPONSE_REQUIRED_FIELDS = "response_required_fields"
|
||||||
|
HEADERS_COOKIES = "headers_cookies"
|
||||||
|
STATE_SEMANTICS = "state_semantics"
|
||||||
|
LONG_TASK_BEHAVIOR = "long_task_behavior"
|
||||||
|
ERRORS_PROHIBITIONS = "errors_prohibitions"
|
||||||
|
PERMISSIONS = "permissions"
|
||||||
|
|
||||||
|
|
||||||
|
EMPTY_DIMENSION_EVIDENCE: Mapping[CompatibilityDimension, frozenset[MethodKey]] = MappingProxyType(
|
||||||
|
{dimension: frozenset() for dimension in CompatibilityDimension}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MethodEvidence(BaseModel):
|
||||||
|
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||||
|
|
||||||
|
path: str
|
||||||
|
verb: str
|
||||||
|
dimensions: tuple[CompatibilityDimension, ...]
|
||||||
|
sources: tuple[str, ...]
|
||||||
|
observed: bool = True
|
||||||
|
verified: bool = True
|
||||||
|
|
||||||
|
@field_validator("sources")
|
||||||
|
@classmethod
|
||||||
|
def require_sources(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
||||||
|
if not value:
|
||||||
|
raise ValueError("evidence record requires at least one source")
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("verb")
|
||||||
|
@classmethod
|
||||||
|
def normalize_verb(cls, value: str) -> str:
|
||||||
|
return value.upper()
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceManifest(BaseModel):
|
||||||
|
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||||
|
|
||||||
|
format_version: int = 1
|
||||||
|
profile: str
|
||||||
|
source_version: str
|
||||||
|
records: tuple[MethodEvidence, ...]
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def reject_duplicate_methods(self) -> EvidenceManifest:
|
||||||
|
keys = [(record.path, record.verb) for record in self.records]
|
||||||
|
if len(keys) != len(set(keys)):
|
||||||
|
raise ValueError("evidence manifest contains duplicate methods")
|
||||||
|
return self
|
||||||
|
|
||||||
|
def dimension_map(self) -> Mapping[CompatibilityDimension, frozenset[MethodKey]]:
|
||||||
|
evidence: dict[CompatibilityDimension, set[MethodKey]] = {
|
||||||
|
dimension: set() for dimension in CompatibilityDimension
|
||||||
|
}
|
||||||
|
for record in self.records:
|
||||||
|
key = (record.path, record.verb.upper())
|
||||||
|
for dimension in record.dimensions:
|
||||||
|
evidence[dimension].add(key)
|
||||||
|
return MappingProxyType(
|
||||||
|
{dimension: frozenset(methods) for dimension, methods in evidence.items()}
|
||||||
|
)
|
||||||
|
|
||||||
|
def observed_methods(self) -> frozenset[MethodKey]:
|
||||||
|
return frozenset(
|
||||||
|
(record.path, record.verb.upper()) for record in self.records if record.observed
|
||||||
|
)
|
||||||
|
|
||||||
|
def verified_methods(self) -> frozenset[MethodKey]:
|
||||||
|
return frozenset(
|
||||||
|
(record.path, record.verb.upper()) for record in self.records if record.verified
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_evidence_manifest(path: Path) -> EvidenceManifest:
|
||||||
|
return EvidenceManifest.model_validate_json(path.read_bytes())
|
||||||
|
|
||||||
|
|
||||||
|
def evidence_dir(settings: object | None = None) -> Path:
|
||||||
|
"""Directory that holds per-version ``pve-{version}.json`` ledgers."""
|
||||||
|
|
||||||
|
evidence = getattr(settings, "compatibility_evidence", None) if settings is not None else None
|
||||||
|
if isinstance(evidence, Path) and evidence.name:
|
||||||
|
return evidence.resolve().parent
|
||||||
|
return Path("evidence")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_evidence_path(source_version: str, settings: object | None = None) -> Path | None:
|
||||||
|
"""Resolve the evidence manifest for a contract ``source_version``.
|
||||||
|
|
||||||
|
Preference order:
|
||||||
|
1. ``evidence/pve-{source_version}.json`` next to the configured evidence file
|
||||||
|
(or ``./evidence`` when unset)
|
||||||
|
2. ``settings.compatibility_evidence`` when its embedded ``source_version`` matches
|
||||||
|
"""
|
||||||
|
|
||||||
|
candidate = evidence_dir(settings) / f"pve-{source_version}.json"
|
||||||
|
if candidate.is_file():
|
||||||
|
return candidate
|
||||||
|
configured = getattr(settings, "compatibility_evidence", None) if settings is not None else None
|
||||||
|
if not isinstance(configured, Path) or not configured.is_file():
|
||||||
|
return None
|
||||||
|
manifesto = load_evidence_manifest(configured)
|
||||||
|
if manifesto.source_version == source_version:
|
||||||
|
return configured
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CompatibilityReport:
|
||||||
|
source_version: str
|
||||||
|
declared: frozenset[MethodKey]
|
||||||
|
schema_only: frozenset[MethodKey]
|
||||||
|
implemented: frozenset[MethodKey]
|
||||||
|
observed: frozenset[MethodKey]
|
||||||
|
verified: frozenset[MethodKey]
|
||||||
|
dimensions: Mapping[CompatibilityDimension, frozenset[MethodKey]]
|
||||||
|
incompatible: frozenset[MethodKey]
|
||||||
|
regressions: frozenset[MethodKey]
|
||||||
|
|
||||||
|
def as_json(self) -> dict[str, object]:
|
||||||
|
levels = {
|
||||||
|
"declared": self.declared,
|
||||||
|
"schema_only": self.schema_only,
|
||||||
|
"implemented": self.implemented,
|
||||||
|
"observed": self.observed,
|
||||||
|
"verified": self.verified,
|
||||||
|
}
|
||||||
|
total = len(self.declared)
|
||||||
|
dimension_sets = tuple(self.dimensions.values())
|
||||||
|
fully_evidenced = (
|
||||||
|
dimension_sets[0].intersection(*dimension_sets[1:]) if dimension_sets else frozenset()
|
||||||
|
)
|
||||||
|
evidenced = frozenset().union(*dimension_sets)
|
||||||
|
fully_compatible = fully_evidenced & self.implemented
|
||||||
|
partially_compatible = (evidenced & self.implemented) - fully_compatible - self.incompatible
|
||||||
|
return {
|
||||||
|
"source_version": self.source_version,
|
||||||
|
"total_declared": total,
|
||||||
|
"levels": {
|
||||||
|
name: {
|
||||||
|
"count": len(methods),
|
||||||
|
"score": len(methods) / total if total else 1.0,
|
||||||
|
"methods": [f"{verb} {path}" for path, verb in sorted(methods)],
|
||||||
|
}
|
||||||
|
for name, methods in levels.items()
|
||||||
|
},
|
||||||
|
"groups": self._groups(),
|
||||||
|
"dimension_groups": self._dimension_groups(),
|
||||||
|
"classifications": {
|
||||||
|
"fully_compatible": self._method_names(fully_compatible),
|
||||||
|
"partially_compatible": self._method_names(partially_compatible),
|
||||||
|
"incompatible": self._method_names(self.incompatible),
|
||||||
|
"regressions": self._method_names(self.regressions),
|
||||||
|
"unsupported": self._method_names(self.schema_only),
|
||||||
|
},
|
||||||
|
"dimensions": {
|
||||||
|
dimension.value: {
|
||||||
|
"count": len(methods),
|
||||||
|
"score": len(methods) / total if total else 1.0,
|
||||||
|
"methods": [f"{verb} {path}" for path, verb in sorted(methods)],
|
||||||
|
}
|
||||||
|
for dimension, methods in self.dimensions.items()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _method_names(methods: frozenset[MethodKey]) -> list[str]:
|
||||||
|
return [f"{verb} {path}" for path, verb in sorted(methods)]
|
||||||
|
|
||||||
|
def canonical_json(self) -> str:
|
||||||
|
return json.dumps(self.as_json(), ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
||||||
|
|
||||||
|
def _groups(self) -> dict[str, dict[str, int]]:
|
||||||
|
groups: dict[str, dict[str, int]] = {}
|
||||||
|
for path, verb in self.declared:
|
||||||
|
group = path.strip("/").split("/", 1)[0] or "root"
|
||||||
|
counters = groups.setdefault(group, {"declared": 0, "implemented": 0, "verified": 0})
|
||||||
|
counters["declared"] += 1
|
||||||
|
counters["implemented"] += int((path, verb) in self.implemented)
|
||||||
|
counters["verified"] += int((path, verb) in self.verified)
|
||||||
|
return dict(sorted(groups.items()))
|
||||||
|
|
||||||
|
def _dimension_groups(self) -> dict[str, dict[str, int]]:
|
||||||
|
groups: dict[str, dict[str, int]] = {}
|
||||||
|
for dimension, methods in self.dimensions.items():
|
||||||
|
for path, _verb in methods:
|
||||||
|
group = path.strip("/").split("/", 1)[0] or "root"
|
||||||
|
counters = groups.setdefault(
|
||||||
|
group, {item.value: 0 for item in CompatibilityDimension}
|
||||||
|
)
|
||||||
|
counters[dimension.value] += 1
|
||||||
|
return dict(sorted(groups.items()))
|
||||||
|
|
||||||
|
def as_markdown(self) -> str:
|
||||||
|
levels = {
|
||||||
|
"declared": self.declared,
|
||||||
|
"schema_only": self.schema_only,
|
||||||
|
"implemented": self.implemented,
|
||||||
|
"observed": self.observed,
|
||||||
|
"verified": self.verified,
|
||||||
|
}
|
||||||
|
total = len(self.declared)
|
||||||
|
lines = [
|
||||||
|
"# Compatibility report",
|
||||||
|
"",
|
||||||
|
"| Level | Count | Score |",
|
||||||
|
"|---|---:|---:|",
|
||||||
|
]
|
||||||
|
for name, methods in levels.items():
|
||||||
|
score = len(methods) / total if total else 1.0
|
||||||
|
lines.append(f"| {name} | {len(methods)} | {score:.2%} |")
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"## Compatibility dimensions",
|
||||||
|
"",
|
||||||
|
"| Dimension | Verified methods | Score |",
|
||||||
|
"|---|---:|---:|",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for dimension, methods in self.dimensions.items():
|
||||||
|
score = len(methods) / total if total else 1.0
|
||||||
|
lines.append(f"| {dimension.value} | {len(methods)} | {score:.2%} |")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def as_html(self) -> str:
|
||||||
|
rows = "".join(
|
||||||
|
"<tr>"
|
||||||
|
f"<td>{escape(dimension.value)}</td>"
|
||||||
|
f"<td>{len(methods)}</td>"
|
||||||
|
f"<td>{(len(methods) / len(self.declared) if self.declared else 1.0):.2%}</td>"
|
||||||
|
"</tr>"
|
||||||
|
for dimension, methods in self.dimensions.items()
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
'<!doctype html><html lang="en"><meta charset="utf-8">'
|
||||||
|
"<title>Compatibility report</title><body>"
|
||||||
|
f"<h1>PVE {escape(self.source_version)} compatibility</h1>"
|
||||||
|
"<table><thead><tr><th>Dimension</th><th>Verified methods</th>"
|
||||||
|
f"<th>Score</th></tr></thead><tbody>{rows}</tbody></table></body></html>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_report(
|
||||||
|
snapshot: Snapshot,
|
||||||
|
*,
|
||||||
|
implemented: frozenset[MethodKey] = frozenset(),
|
||||||
|
observed: frozenset[MethodKey] = frozenset(),
|
||||||
|
verified: frozenset[MethodKey] = frozenset(),
|
||||||
|
dimensions: Mapping[CompatibilityDimension, frozenset[MethodKey]] = EMPTY_DIMENSION_EVIDENCE,
|
||||||
|
incompatible: frozenset[MethodKey] = frozenset(),
|
||||||
|
regressions: frozenset[MethodKey] = frozenset(),
|
||||||
|
) -> CompatibilityReport:
|
||||||
|
declared = frozenset(
|
||||||
|
(path.path, method.verb.upper()) for path in snapshot.paths for method in path.methods
|
||||||
|
)
|
||||||
|
for name, evidence in {
|
||||||
|
"implemented": implemented,
|
||||||
|
"observed": observed,
|
||||||
|
"verified": verified,
|
||||||
|
"incompatible": incompatible,
|
||||||
|
"regressions": regressions,
|
||||||
|
}.items():
|
||||||
|
if not evidence <= declared:
|
||||||
|
raise ValueError(f"{name} evidence references undeclared methods")
|
||||||
|
resolved_dimensions = {
|
||||||
|
dimension: frozenset(dimensions.get(dimension, frozenset()))
|
||||||
|
for dimension in CompatibilityDimension
|
||||||
|
}
|
||||||
|
for dimension, evidence in resolved_dimensions.items():
|
||||||
|
if not evidence <= declared:
|
||||||
|
raise ValueError(f"{dimension.value} evidence references undeclared methods")
|
||||||
|
return CompatibilityReport(
|
||||||
|
source_version=snapshot.source_version,
|
||||||
|
declared=declared,
|
||||||
|
schema_only=declared - implemented,
|
||||||
|
implemented=implemented,
|
||||||
|
observed=observed,
|
||||||
|
verified=verified,
|
||||||
|
dimensions=MappingProxyType(resolved_dimensions),
|
||||||
|
incompatible=incompatible,
|
||||||
|
regressions=regressions,
|
||||||
|
)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""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 = "openstack-api-simulator"
|
||||||
|
app_host: str = "0.0.0.0" # noqa: S104 - the container must accept external traffic
|
||||||
|
# Internal listen port. Public OpenStack service ports are published by api-gateway.
|
||||||
|
app_port: int = Field(default=8080, ge=1, le=65535)
|
||||||
|
database_url: SecretStr = SecretStr(
|
||||||
|
"postgresql://openstack:openstack@localhost:5432/openstack_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://openstack/yoga/api-contract"
|
||||||
|
catalog_artifact_url_7: str = "stub://openstack/antelope/api-contract"
|
||||||
|
catalog_artifact_url_8: str = "stub://openstack/caracal/api-contract"
|
||||||
|
catalog_artifact_url_9: str = "stub://openstack/dalmatian/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)
|
||||||
|
|
||||||
|
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 @@
|
|||||||
|
"""Authoritative API contract ingestion and normalization."""
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Command-line interface for contract imports and inspection."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.contracts.diff import (
|
||||||
|
compare_snapshots,
|
||||||
|
has_breaking_changes,
|
||||||
|
render_html,
|
||||||
|
render_json,
|
||||||
|
render_markdown,
|
||||||
|
render_text,
|
||||||
|
)
|
||||||
|
from app.contracts.importer import RemoteSourceImporter
|
||||||
|
from app.contracts.model import Snapshot
|
||||||
|
from app.contracts.normalize import normalize_snapshot
|
||||||
|
from app.contracts.source import ApiViewerParser, LocalFileImporter, SourceImporter
|
||||||
|
from app.contracts.store import RevisionStore
|
||||||
|
|
||||||
|
|
||||||
|
def parser() -> argparse.ArgumentParser:
|
||||||
|
root = argparse.ArgumentParser(prog="proxmox-api-contract")
|
||||||
|
root.add_argument("--store", type=Path, default=Path("contracts"))
|
||||||
|
commands = root.add_subparsers(dest="command", required=True)
|
||||||
|
import_command = commands.add_parser("import")
|
||||||
|
source = import_command.add_mutually_exclusive_group(required=True)
|
||||||
|
source.add_argument("--file", type=Path)
|
||||||
|
source.add_argument("--url")
|
||||||
|
import_command.add_argument("--version", required=True)
|
||||||
|
validate = commands.add_parser("validate")
|
||||||
|
validate.add_argument("file", type=Path)
|
||||||
|
commands.add_parser("list")
|
||||||
|
show = commands.add_parser("show")
|
||||||
|
show.add_argument("revision")
|
||||||
|
diff = commands.add_parser("diff")
|
||||||
|
diff.add_argument("before", type=Path)
|
||||||
|
diff.add_argument("after", type=Path)
|
||||||
|
diff.add_argument("--format", choices=("text", "json", "markdown", "html"), default="text")
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
async def run(arguments: argparse.Namespace) -> int:
|
||||||
|
store = RevisionStore(arguments.store)
|
||||||
|
if arguments.command == "list":
|
||||||
|
for revision in store.list():
|
||||||
|
print(revision)
|
||||||
|
return 0
|
||||||
|
if arguments.command == "show":
|
||||||
|
print(json.dumps(store.manifest(arguments.revision).model_dump(mode="json"), indent=2))
|
||||||
|
return 0
|
||||||
|
if arguments.command == "diff":
|
||||||
|
before = Snapshot.model_validate_json(arguments.before.read_bytes())
|
||||||
|
after = Snapshot.model_validate_json(arguments.after.read_bytes())
|
||||||
|
changes = compare_snapshots(before, after)
|
||||||
|
renderers = {
|
||||||
|
"text": render_text,
|
||||||
|
"json": render_json,
|
||||||
|
"markdown": render_markdown,
|
||||||
|
"html": render_html,
|
||||||
|
}
|
||||||
|
print(renderers[arguments.format](changes))
|
||||||
|
return 1 if has_breaking_changes(changes) else 0
|
||||||
|
if arguments.command == "validate":
|
||||||
|
parsed = ApiViewerParser().parse(arguments.file.read_bytes())
|
||||||
|
print(json.dumps({"nodes": len(parsed.nodes), "warnings": len(parsed.warnings)}))
|
||||||
|
return 0
|
||||||
|
importer: SourceImporter
|
||||||
|
if arguments.file is not None:
|
||||||
|
importer = LocalFileImporter(arguments.file)
|
||||||
|
else:
|
||||||
|
importer = RemoteSourceImporter(arguments.url)
|
||||||
|
raw = await importer.load()
|
||||||
|
parsed = ApiViewerParser().parse(raw)
|
||||||
|
snapshot, manifest = normalize_snapshot(
|
||||||
|
parsed, raw=raw, source_version=arguments.version, retrieved_at=datetime.now(UTC)
|
||||||
|
)
|
||||||
|
print(store.save(raw, snapshot, manifest))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
raise SystemExit(asyncio.run(run(parser().parse_args())))
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
"""Deterministic semantic differences between normalized snapshots."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.contracts.model import Method, Parameter, PathContract, Snapshot
|
||||||
|
|
||||||
|
|
||||||
|
class Severity(StrEnum):
|
||||||
|
BREAKING = "breaking"
|
||||||
|
NON_BREAKING = "non-breaking"
|
||||||
|
DOCUMENTATION = "documentation"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True, order=True)
|
||||||
|
class Change:
|
||||||
|
path: str
|
||||||
|
method: str
|
||||||
|
category: str
|
||||||
|
severity: Severity
|
||||||
|
detail: str
|
||||||
|
before: str | None = None
|
||||||
|
after: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _stable(value: Any) -> str:
|
||||||
|
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
|
def _methods(snapshot: Snapshot) -> dict[tuple[str, str], Method]:
|
||||||
|
return {(path.path, method.verb): method for path in snapshot.paths for method in path.methods}
|
||||||
|
|
||||||
|
|
||||||
|
def _paths(snapshot: Snapshot) -> dict[str, PathContract]:
|
||||||
|
return {path.path: path for path in snapshot.paths}
|
||||||
|
|
||||||
|
|
||||||
|
def compare_snapshots(before: Snapshot, after: Snapshot) -> tuple[Change, ...]:
|
||||||
|
changes: list[Change] = []
|
||||||
|
old_paths, new_paths = _paths(before), _paths(after)
|
||||||
|
for path in sorted(old_paths.keys() - new_paths.keys()):
|
||||||
|
changes.append(Change(path, "", "path", Severity.BREAKING, "path removed"))
|
||||||
|
for path in sorted(new_paths.keys() - old_paths.keys()):
|
||||||
|
changes.append(Change(path, "", "path", Severity.NON_BREAKING, "path added"))
|
||||||
|
|
||||||
|
old_methods, new_methods = _methods(before), _methods(after)
|
||||||
|
for path, verb in sorted(old_methods.keys() - new_methods.keys()):
|
||||||
|
changes.append(Change(path, verb, "method", Severity.BREAKING, "method removed"))
|
||||||
|
for path, verb in sorted(new_methods.keys() - old_methods.keys()):
|
||||||
|
changes.append(Change(path, verb, "method", Severity.NON_BREAKING, "method added"))
|
||||||
|
for key in sorted(old_methods.keys() & new_methods.keys()):
|
||||||
|
_compare_method(key, old_methods[key], new_methods[key], changes)
|
||||||
|
return tuple(sorted(changes))
|
||||||
|
|
||||||
|
|
||||||
|
def _compare_method(
|
||||||
|
key: tuple[str, str], before: Method, after: Method, changes: list[Change]
|
||||||
|
) -> None:
|
||||||
|
path, verb = key
|
||||||
|
if before.description != after.description:
|
||||||
|
changes.append(
|
||||||
|
Change(
|
||||||
|
path,
|
||||||
|
verb,
|
||||||
|
"documentation",
|
||||||
|
Severity.DOCUMENTATION,
|
||||||
|
"description changed",
|
||||||
|
before.description,
|
||||||
|
after.description,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if before.permissions != after.permissions:
|
||||||
|
changes.append(
|
||||||
|
Change(
|
||||||
|
path,
|
||||||
|
verb,
|
||||||
|
"permissions",
|
||||||
|
Severity.BREAKING,
|
||||||
|
"permissions changed",
|
||||||
|
_stable(before.permissions.model_dump(mode="json") if before.permissions else None),
|
||||||
|
_stable(after.permissions.model_dump(mode="json") if after.permissions else None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_compare_parameters(path, verb, before.parameters, after.parameters, changes)
|
||||||
|
_compare_schema(
|
||||||
|
path,
|
||||||
|
verb,
|
||||||
|
"returns",
|
||||||
|
before.returns.model_dump(mode="json"),
|
||||||
|
after.returns.model_dump(mode="json"),
|
||||||
|
changes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _compare_parameters(
|
||||||
|
path: str,
|
||||||
|
verb: str,
|
||||||
|
before: tuple[Parameter, ...],
|
||||||
|
after: tuple[Parameter, ...],
|
||||||
|
changes: list[Change],
|
||||||
|
) -> None:
|
||||||
|
old = {parameter.name: parameter for parameter in before}
|
||||||
|
new = {parameter.name: parameter for parameter in after}
|
||||||
|
for name in sorted(old.keys() - new.keys()):
|
||||||
|
changes.append(Change(path, verb, "parameter", Severity.BREAKING, f"removed: {name}"))
|
||||||
|
for name in sorted(new.keys() - old.keys()):
|
||||||
|
severity = Severity.NON_BREAKING if new[name].definition.optional else Severity.BREAKING
|
||||||
|
changes.append(Change(path, verb, "parameter", severity, f"added: {name}"))
|
||||||
|
for name in sorted(old.keys() & new.keys()):
|
||||||
|
_compare_schema(
|
||||||
|
path,
|
||||||
|
verb,
|
||||||
|
f"parameter:{name}",
|
||||||
|
old[name].definition.model_dump(mode="json"),
|
||||||
|
new[name].definition.model_dump(mode="json"),
|
||||||
|
changes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _compare_schema(
|
||||||
|
path: str,
|
||||||
|
verb: str,
|
||||||
|
label: str,
|
||||||
|
before: dict[str, Any],
|
||||||
|
after: dict[str, Any],
|
||||||
|
changes: list[Change],
|
||||||
|
) -> None:
|
||||||
|
groups = {
|
||||||
|
"schema": {"type", "properties", "items", "enum", "format", "pattern"},
|
||||||
|
"default": {"default", "optional"},
|
||||||
|
"constraint": {"minimum", "maximum", "min_length", "max_length"},
|
||||||
|
"documentation": {"description"},
|
||||||
|
}
|
||||||
|
for category, fields in groups.items():
|
||||||
|
old = {field: before.get(field) for field in fields}
|
||||||
|
new = {field: after.get(field) for field in fields}
|
||||||
|
if old != new:
|
||||||
|
severity = Severity.DOCUMENTATION if category == "documentation" else Severity.BREAKING
|
||||||
|
changes.append(
|
||||||
|
Change(
|
||||||
|
path,
|
||||||
|
verb,
|
||||||
|
category,
|
||||||
|
severity,
|
||||||
|
f"{label} {category} changed",
|
||||||
|
_stable(old),
|
||||||
|
_stable(new),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_json(changes: tuple[Change, ...]) -> str:
|
||||||
|
return json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"after": change.after,
|
||||||
|
"before": change.before,
|
||||||
|
"category": change.category,
|
||||||
|
"detail": change.detail,
|
||||||
|
"method": change.method,
|
||||||
|
"path": change.path,
|
||||||
|
"severity": change.severity,
|
||||||
|
}
|
||||||
|
for change in changes
|
||||||
|
],
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_text(changes: tuple[Change, ...]) -> str:
|
||||||
|
return "\n".join(
|
||||||
|
(
|
||||||
|
f"{change.severity}: {change.method} {change.path} [{change.category}] {change.detail}"
|
||||||
|
).strip()
|
||||||
|
for change in changes
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_markdown(changes: tuple[Change, ...]) -> str:
|
||||||
|
lines = [
|
||||||
|
"# API contract diff",
|
||||||
|
"",
|
||||||
|
"| Severity | Method | Path | Category | Detail |",
|
||||||
|
"|---|---|---|---|---|",
|
||||||
|
]
|
||||||
|
lines.extend(
|
||||||
|
(
|
||||||
|
f"| {change.severity} | {change.method} | `{change.path}` | "
|
||||||
|
f"{change.category} | {change.detail} |"
|
||||||
|
)
|
||||||
|
for change in changes
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def render_html(changes: tuple[Change, ...]) -> str:
|
||||||
|
rows = "".join(
|
||||||
|
"<tr>"
|
||||||
|
+ "".join(
|
||||||
|
f"<td>{html.escape(str(value))}</td>"
|
||||||
|
for value in (
|
||||||
|
change.severity,
|
||||||
|
change.method,
|
||||||
|
change.path,
|
||||||
|
change.category,
|
||||||
|
change.detail,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
+ "</tr>"
|
||||||
|
for change in changes
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"<!doctype html><meta charset=utf-8><title>API contract diff</title><table>{rows}</table>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def has_breaking_changes(changes: tuple[Change, ...]) -> bool:
|
||||||
|
return any(change.severity is Severity.BREAKING for change in changes)
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Generate example values from Proxmox contract schemas."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.contracts.model import Schema
|
||||||
|
|
||||||
|
_PATH_PARAM_EXAMPLES: dict[str, object] = {
|
||||||
|
"node": "pve01",
|
||||||
|
"vmid": 100,
|
||||||
|
"storage": "local",
|
||||||
|
"pool": "testpool",
|
||||||
|
"userid": "root@pam",
|
||||||
|
"tokenid": "automation",
|
||||||
|
"realm": "pam",
|
||||||
|
"group": "admins",
|
||||||
|
"role": "Administrator",
|
||||||
|
"upid": "UPID:pve01:00000001:00000001:65000001:qmstart:100:root@pam:",
|
||||||
|
"snapname": "snap1",
|
||||||
|
"volume": "local:100/vm-100-disk-0.qcow2",
|
||||||
|
"disk": "scsi0",
|
||||||
|
"iface": "net0",
|
||||||
|
"key": "cpu",
|
||||||
|
"digest": "00000000",
|
||||||
|
"name": "example",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def path_param_example(name: str) -> object | None:
|
||||||
|
"""Return a realistic placeholder for a common Proxmox path parameter."""
|
||||||
|
|
||||||
|
return _PATH_PARAM_EXAMPLES.get(name)
|
||||||
|
|
||||||
|
|
||||||
|
def schema_example(schema: Schema, *, name: str | None = None) -> object:
|
||||||
|
"""Build a representative example value for a contract schema."""
|
||||||
|
|
||||||
|
if schema.default is not None:
|
||||||
|
return schema.default
|
||||||
|
if schema.enum:
|
||||||
|
return schema.enum[0]
|
||||||
|
if name is not None:
|
||||||
|
hinted = path_param_example(name)
|
||||||
|
if hinted is not None:
|
||||||
|
return hinted
|
||||||
|
if "[n]" in name:
|
||||||
|
indexed = name.replace("[n]", "0")
|
||||||
|
hinted = path_param_example(indexed.rstrip("0123456789"))
|
||||||
|
if hinted is not None:
|
||||||
|
return hinted
|
||||||
|
if schema.type == "array":
|
||||||
|
if schema.items is not None:
|
||||||
|
return [schema_example(schema.items)]
|
||||||
|
return []
|
||||||
|
if schema.type == "object":
|
||||||
|
return {
|
||||||
|
key: schema_example(definition, name=key)
|
||||||
|
for key, definition in schema.properties.items()
|
||||||
|
if not definition.optional
|
||||||
|
}
|
||||||
|
if schema.type == "boolean":
|
||||||
|
return False
|
||||||
|
if schema.type == "integer":
|
||||||
|
if schema.minimum is not None:
|
||||||
|
return int(schema.minimum)
|
||||||
|
return 1
|
||||||
|
if schema.type == "number":
|
||||||
|
if schema.minimum is not None:
|
||||||
|
return float(schema.minimum)
|
||||||
|
return 1.0
|
||||||
|
if schema.type == "string" or schema.type is None:
|
||||||
|
if schema.format == "email":
|
||||||
|
return "user@example.com"
|
||||||
|
if schema.format == "uri":
|
||||||
|
return "https://example.com"
|
||||||
|
return "example"
|
||||||
|
return None
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""Network-constrained remote contract retrieval."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import ipaddress
|
||||||
|
import socket
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from urllib.parse import urljoin, urlsplit
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.contracts.source import SourceError
|
||||||
|
|
||||||
|
Resolver = Callable[[str], Awaitable[tuple[str, ...]]]
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_host(host: str) -> tuple[str, ...]:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
results = await loop.getaddrinfo(host, 443, type=socket.SOCK_STREAM)
|
||||||
|
return tuple(sorted({str(result[4][0]) for result in results}))
|
||||||
|
|
||||||
|
|
||||||
|
def validate_remote_url(url: str, allowed_hosts: frozenset[str]) -> str:
|
||||||
|
parsed = urlsplit(url)
|
||||||
|
if parsed.scheme != "https":
|
||||||
|
raise SourceError("remote imports require HTTPS")
|
||||||
|
if parsed.username or parsed.password or parsed.port not in (None, 443):
|
||||||
|
raise SourceError("remote URL contains forbidden authority components")
|
||||||
|
host = (parsed.hostname or "").rstrip(".").lower()
|
||||||
|
if host not in allowed_hosts:
|
||||||
|
raise SourceError("remote host is not in the official-domain allowlist")
|
||||||
|
if parsed.fragment:
|
||||||
|
raise SourceError("remote URL fragments are not allowed")
|
||||||
|
return host
|
||||||
|
|
||||||
|
|
||||||
|
# Fake-IP pools used by local proxies (Clash, Surge, etc.) still route to public hosts.
|
||||||
|
_FAKE_IP_NETWORK = ipaddress.ip_network("198.18.0.0/15")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_allowed_resolved_address(address: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
||||||
|
if address.is_global:
|
||||||
|
return True
|
||||||
|
mapped = address.ipv4_mapped if isinstance(address, ipaddress.IPv6Address) else None
|
||||||
|
if mapped is not None and mapped in _FAKE_IP_NETWORK:
|
||||||
|
return True
|
||||||
|
if isinstance(address, ipaddress.IPv4Address) and address in _FAKE_IP_NETWORK:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def validate_public_addresses(addresses: tuple[str, ...]) -> None:
|
||||||
|
if not addresses:
|
||||||
|
raise SourceError("remote host did not resolve")
|
||||||
|
for value in addresses:
|
||||||
|
address = ipaddress.ip_address(value)
|
||||||
|
if not _is_allowed_resolved_address(address):
|
||||||
|
raise SourceError(f"remote host resolved to a non-public address: {value}")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RemoteSourceImporter:
|
||||||
|
url: str
|
||||||
|
allowed_hosts: frozenset[str] = frozenset({"pve.proxmox.com"})
|
||||||
|
max_bytes: int = 16 * 1024 * 1024
|
||||||
|
max_redirects: int = 3
|
||||||
|
retries: int = 2
|
||||||
|
timeout_seconds: float = 20.0
|
||||||
|
resolver: Resolver = resolve_host
|
||||||
|
transport: httpx.AsyncBaseTransport | None = None
|
||||||
|
|
||||||
|
async def load(self) -> bytes:
|
||||||
|
current = self.url
|
||||||
|
timeout = httpx.Timeout(self.timeout_seconds)
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
follow_redirects=False, timeout=timeout, transport=self.transport
|
||||||
|
) as client:
|
||||||
|
for redirect_count in range(self.max_redirects + 1):
|
||||||
|
host = validate_remote_url(current, self.allowed_hosts)
|
||||||
|
validate_public_addresses(await self.resolver(host))
|
||||||
|
response = await self._request(client, current)
|
||||||
|
if response.is_redirect:
|
||||||
|
if redirect_count == self.max_redirects:
|
||||||
|
raise SourceError("remote import exceeded redirect limit")
|
||||||
|
location = response.headers.get("location")
|
||||||
|
if not location:
|
||||||
|
raise SourceError("remote redirect has no location")
|
||||||
|
current = urljoin(current, location)
|
||||||
|
continue
|
||||||
|
response.raise_for_status()
|
||||||
|
content_length = response.headers.get("content-length")
|
||||||
|
if content_length and int(content_length) > self.max_bytes:
|
||||||
|
raise SourceError("remote artifact exceeds size limit")
|
||||||
|
content = response.content
|
||||||
|
if len(content) > self.max_bytes:
|
||||||
|
raise SourceError("remote artifact exceeds size limit")
|
||||||
|
return content
|
||||||
|
raise SourceError("remote import failed")
|
||||||
|
|
||||||
|
async def _request(self, client: httpx.AsyncClient, url: str) -> httpx.Response:
|
||||||
|
for attempt in range(self.retries + 1):
|
||||||
|
try:
|
||||||
|
return await client.get(url)
|
||||||
|
except (httpx.TimeoutException, httpx.NetworkError):
|
||||||
|
if attempt == self.retries:
|
||||||
|
raise
|
||||||
|
await asyncio.sleep(0.1 * (2**attempt))
|
||||||
|
raise SourceError("remote import retry loop exhausted")
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""Immutable normalized representation of Proxmox API contracts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Self
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
type JsonValue = None | bool | int | float | str | list[JsonValue] | dict[str, JsonValue]
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_json(value: BaseModel | Mapping[str, Any] | Sequence[Any]) -> bytes:
|
||||||
|
"""Serialize a JSON-compatible value deterministically as UTF-8."""
|
||||||
|
|
||||||
|
data: Any
|
||||||
|
if isinstance(value, BaseModel):
|
||||||
|
data = value.model_dump(mode="json", exclude_none=True)
|
||||||
|
else:
|
||||||
|
data = value
|
||||||
|
return json.dumps(
|
||||||
|
data,
|
||||||
|
default=_json_default,
|
||||||
|
ensure_ascii=False,
|
||||||
|
allow_nan=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
).encode()
|
||||||
|
|
||||||
|
|
||||||
|
def _json_default(value: object) -> Any:
|
||||||
|
if isinstance(value, BaseModel):
|
||||||
|
return value.model_dump(mode="json", exclude_none=True)
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.isoformat()
|
||||||
|
raise TypeError(f"{type(value).__name__} is not JSON serializable")
|
||||||
|
|
||||||
|
|
||||||
|
def sha256(value: bytes) -> str:
|
||||||
|
return hashlib.sha256(value).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class FrozenModel(BaseModel):
|
||||||
|
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
|
class Schema(FrozenModel):
|
||||||
|
"""Proxmox's JSON-Schema-like dialect with retained extensions."""
|
||||||
|
|
||||||
|
type: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
properties: dict[str, Schema] = Field(default_factory=dict)
|
||||||
|
items: Schema | None = None
|
||||||
|
enum: tuple[JsonValue, ...] = ()
|
||||||
|
optional: bool | None = None
|
||||||
|
default: JsonValue = None
|
||||||
|
minimum: int | float | None = None
|
||||||
|
maximum: int | float | None = None
|
||||||
|
min_length: int | None = None
|
||||||
|
max_length: int | None = None
|
||||||
|
pattern: str | None = None
|
||||||
|
format: str | dict[str, JsonValue] | None = None
|
||||||
|
extra: dict[str, JsonValue] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class Parameter(FrozenModel):
|
||||||
|
name: str
|
||||||
|
definition: Schema
|
||||||
|
|
||||||
|
|
||||||
|
class Permissions(FrozenModel):
|
||||||
|
user: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
expression: dict[str, JsonValue] = Field(default_factory=dict)
|
||||||
|
extra: dict[str, JsonValue] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class Method(FrozenModel):
|
||||||
|
verb: str
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
parameters: tuple[Parameter, ...] = ()
|
||||||
|
returns: Schema = Field(default_factory=Schema)
|
||||||
|
permissions: Permissions | None = None
|
||||||
|
protected: bool = False
|
||||||
|
allow_token: bool | None = None
|
||||||
|
extra: dict[str, JsonValue] = Field(default_factory=dict)
|
||||||
|
checksum: str
|
||||||
|
|
||||||
|
|
||||||
|
class PathContract(FrozenModel):
|
||||||
|
path: str
|
||||||
|
methods: tuple[Method, ...]
|
||||||
|
extra: dict[str, JsonValue] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class Snapshot(FrozenModel):
|
||||||
|
format_version: int = 1
|
||||||
|
source_version: str
|
||||||
|
retrieved_at: datetime
|
||||||
|
raw_sha256: str
|
||||||
|
paths: tuple[PathContract, ...]
|
||||||
|
path_count: int
|
||||||
|
method_count: int
|
||||||
|
extra: dict[str, JsonValue] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_counts_and_uniqueness(self) -> Self:
|
||||||
|
if self.path_count != len(self.paths):
|
||||||
|
raise ValueError("path_count does not match paths")
|
||||||
|
methods = sum(len(path.methods) for path in self.paths)
|
||||||
|
if self.method_count != methods:
|
||||||
|
raise ValueError("method_count does not match methods")
|
||||||
|
keys = [(path.path, method.verb) for path in self.paths for method in path.methods]
|
||||||
|
if len(keys) != len(set(keys)):
|
||||||
|
raise ValueError("duplicate path and method")
|
||||||
|
return self
|
||||||
|
|
||||||
|
def canonical_bytes(self) -> bytes:
|
||||||
|
return canonical_json(self)
|
||||||
|
|
||||||
|
def checksum(self) -> str:
|
||||||
|
return sha256(self.canonical_bytes())
|
||||||
|
|
||||||
|
|
||||||
|
class Manifest(FrozenModel):
|
||||||
|
source_version: str
|
||||||
|
raw_sha256: str
|
||||||
|
snapshot_sha256: str
|
||||||
|
path_count: int
|
||||||
|
method_count: int
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"""Normalize parsed API Viewer trees into stable contract models."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from app.contracts.model import (
|
||||||
|
JsonValue,
|
||||||
|
Manifest,
|
||||||
|
Method,
|
||||||
|
Parameter,
|
||||||
|
PathContract,
|
||||||
|
Permissions,
|
||||||
|
Schema,
|
||||||
|
Snapshot,
|
||||||
|
canonical_json,
|
||||||
|
sha256,
|
||||||
|
)
|
||||||
|
from app.contracts.source import ParsedSource
|
||||||
|
|
||||||
|
SCHEMA_FIELDS = {
|
||||||
|
"type",
|
||||||
|
"description",
|
||||||
|
"properties",
|
||||||
|
"items",
|
||||||
|
"enum",
|
||||||
|
"optional",
|
||||||
|
"default",
|
||||||
|
"minimum",
|
||||||
|
"maximum",
|
||||||
|
"minLength",
|
||||||
|
"maxLength",
|
||||||
|
"pattern",
|
||||||
|
"format",
|
||||||
|
}
|
||||||
|
METHOD_FIELDS = {
|
||||||
|
"allowtoken",
|
||||||
|
"description",
|
||||||
|
"method",
|
||||||
|
"name",
|
||||||
|
"parameters",
|
||||||
|
"permissions",
|
||||||
|
"protected",
|
||||||
|
"returns",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _json(value: Any) -> JsonValue:
|
||||||
|
return cast(JsonValue, value)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_schema(raw: Mapping[str, Any] | None) -> Schema:
|
||||||
|
source = raw or {}
|
||||||
|
properties = source.get("properties") or {}
|
||||||
|
normalized_properties = {
|
||||||
|
str(name): normalize_schema(cast(Mapping[str, Any], schema))
|
||||||
|
for name, schema in cast(Mapping[str, Any], properties).items()
|
||||||
|
}
|
||||||
|
items = source.get("items")
|
||||||
|
extra = {key: _json(value) for key, value in source.items() if key not in SCHEMA_FIELDS}
|
||||||
|
return Schema(
|
||||||
|
type=source.get("type"),
|
||||||
|
description=source.get("description"),
|
||||||
|
properties=normalized_properties,
|
||||||
|
items=normalize_schema(cast(Mapping[str, Any], items))
|
||||||
|
if isinstance(items, Mapping)
|
||||||
|
else None,
|
||||||
|
enum=tuple(_json(value) for value in (source.get("enum") or ())),
|
||||||
|
optional=bool(source["optional"]) if "optional" in source else None,
|
||||||
|
default=_json(source.get("default")),
|
||||||
|
minimum=source.get("minimum"),
|
||||||
|
maximum=source.get("maximum"),
|
||||||
|
min_length=source.get("minLength"),
|
||||||
|
max_length=source.get("maxLength"),
|
||||||
|
pattern=source.get("pattern"),
|
||||||
|
format=_json(source.get("format")),
|
||||||
|
extra=extra,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_permissions(raw: Mapping[str, Any] | None) -> Permissions | None:
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
known = {"user", "description"}
|
||||||
|
expression_keys = {"and", "or", "check", "userParam"}
|
||||||
|
return Permissions(
|
||||||
|
user=raw.get("user"),
|
||||||
|
description=raw.get("description"),
|
||||||
|
expression={key: _json(raw[key]) for key in expression_keys if key in raw},
|
||||||
|
extra={
|
||||||
|
key: _json(value) for key, value in raw.items() if key not in known | expression_keys
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_method(verb: str, raw: Mapping[str, Any]) -> Method:
|
||||||
|
parameters_raw = cast(Mapping[str, Any], raw.get("parameters") or {}).get("properties") or {}
|
||||||
|
parameters = tuple(
|
||||||
|
Parameter(name=str(name), definition=normalize_schema(cast(Mapping[str, Any], schema)))
|
||||||
|
for name, schema in sorted(cast(Mapping[str, Any], parameters_raw).items())
|
||||||
|
)
|
||||||
|
values: dict[str, Any] = {
|
||||||
|
"verb": verb.upper(),
|
||||||
|
"name": str(raw.get("name", verb.lower())),
|
||||||
|
"description": raw.get("description"),
|
||||||
|
"parameters": parameters,
|
||||||
|
"returns": normalize_schema(cast(Mapping[str, Any] | None, raw.get("returns"))),
|
||||||
|
"permissions": normalize_permissions(
|
||||||
|
cast(Mapping[str, Any] | None, raw.get("permissions"))
|
||||||
|
),
|
||||||
|
"protected": bool(raw.get("protected", False)),
|
||||||
|
"allow_token": bool(raw["allowtoken"]) if "allowtoken" in raw else None,
|
||||||
|
"extra": {key: _json(value) for key, value in raw.items() if key not in METHOD_FIELDS},
|
||||||
|
}
|
||||||
|
checksum = sha256(canonical_json(values))
|
||||||
|
return Method(**values, checksum=checksum)
|
||||||
|
|
||||||
|
|
||||||
|
def _walk(nodes: tuple[dict[str, Any], ...]) -> list[PathContract]:
|
||||||
|
paths: list[PathContract] = []
|
||||||
|
|
||||||
|
def visit(node: Mapping[str, Any]) -> None:
|
||||||
|
info = node.get("info")
|
||||||
|
path = node.get("path")
|
||||||
|
if isinstance(info, Mapping) and isinstance(path, str):
|
||||||
|
methods = tuple(
|
||||||
|
normalize_method(str(verb), cast(Mapping[str, Any], method))
|
||||||
|
for verb, method in sorted(info.items())
|
||||||
|
if isinstance(method, Mapping)
|
||||||
|
)
|
||||||
|
extra = {
|
||||||
|
key: _json(value)
|
||||||
|
for key, value in node.items()
|
||||||
|
if key not in {"children", "info", "leaf", "path", "text"}
|
||||||
|
}
|
||||||
|
paths.append(PathContract(path=path, methods=methods, extra=extra))
|
||||||
|
for child in node.get("children", ()):
|
||||||
|
if isinstance(child, Mapping):
|
||||||
|
visit(child)
|
||||||
|
|
||||||
|
for root in nodes:
|
||||||
|
visit(root)
|
||||||
|
return sorted(paths, key=lambda item: item.path)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_snapshot(
|
||||||
|
parsed: ParsedSource, *, raw: bytes, source_version: str, retrieved_at: datetime
|
||||||
|
) -> tuple[Snapshot, Manifest]:
|
||||||
|
paths = tuple(_walk(parsed.nodes))
|
||||||
|
snapshot = Snapshot(
|
||||||
|
source_version=source_version,
|
||||||
|
retrieved_at=retrieved_at,
|
||||||
|
raw_sha256=sha256(raw),
|
||||||
|
paths=paths,
|
||||||
|
path_count=len(paths),
|
||||||
|
method_count=sum(len(path.methods) for path in paths),
|
||||||
|
extra={"warning_count": len(parsed.warnings)},
|
||||||
|
)
|
||||||
|
manifest = Manifest(
|
||||||
|
source_version=source_version,
|
||||||
|
raw_sha256=snapshot.raw_sha256,
|
||||||
|
snapshot_sha256=snapshot.checksum(),
|
||||||
|
path_count=snapshot.path_count,
|
||||||
|
method_count=snapshot.method_count,
|
||||||
|
)
|
||||||
|
return snapshot, manifest
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"""In-memory runtime contract hot-swap helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request, Response
|
||||||
|
from starlette.routing import Route
|
||||||
|
|
||||||
|
from app.api.registry import (
|
||||||
|
FallbackMode,
|
||||||
|
HandlerRegistry,
|
||||||
|
register_contract_routes,
|
||||||
|
register_legacy_handler_routes,
|
||||||
|
)
|
||||||
|
from app.compatibility import (
|
||||||
|
CompatibilityDimension,
|
||||||
|
CompatibilityReport,
|
||||||
|
build_report,
|
||||||
|
load_evidence_manifest,
|
||||||
|
resolve_evidence_path,
|
||||||
|
)
|
||||||
|
from app.config import Settings
|
||||||
|
from app.contracts.model import Snapshot
|
||||||
|
|
||||||
|
_ADMIN_ROUTE_NAMES = frozenset(
|
||||||
|
{
|
||||||
|
"admin:compatibility",
|
||||||
|
"admin:compatibility.md",
|
||||||
|
"admin:compatibility.html",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_contract_routes(app: FastAPI) -> None:
|
||||||
|
"""Drop previously registered contract (and optional admin) routes for rebuild."""
|
||||||
|
|
||||||
|
app.router.routes = [route for route in app.router.routes if not _is_swappable_route(route)]
|
||||||
|
app.openapi_schema = None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_swappable_route(route: object) -> bool:
|
||||||
|
name = getattr(route, "name", None)
|
||||||
|
if not isinstance(name, str):
|
||||||
|
return False
|
||||||
|
return name.startswith("contract:") or name in _ADMIN_ROUTE_NAMES
|
||||||
|
|
||||||
|
|
||||||
|
def build_compatibility_for_snapshot(
|
||||||
|
snapshot: Snapshot,
|
||||||
|
handlers: HandlerRegistry,
|
||||||
|
settings: Settings,
|
||||||
|
*,
|
||||||
|
require_evidence_match: bool = False,
|
||||||
|
) -> CompatibilityReport:
|
||||||
|
"""Build a compatibility report for the active primary snapshot.
|
||||||
|
|
||||||
|
Evidence is resolved per ``snapshot.source_version``
|
||||||
|
(``evidence/pve-{version}.json``). When ``require_evidence_match`` is true
|
||||||
|
(cold start) a missing or mismatched ledger raises.
|
||||||
|
"""
|
||||||
|
|
||||||
|
declared = frozenset(
|
||||||
|
(path.path, method.verb.upper()) for path in snapshot.paths for method in path.methods
|
||||||
|
)
|
||||||
|
dimensions: dict[CompatibilityDimension, frozenset[tuple[str, str]]] = {
|
||||||
|
CompatibilityDimension.ROUTE_METHOD: declared,
|
||||||
|
}
|
||||||
|
observed: frozenset[tuple[str, str]] = frozenset()
|
||||||
|
verified: frozenset[tuple[str, str]] = frozenset()
|
||||||
|
evidence_path = resolve_evidence_path(snapshot.source_version, settings)
|
||||||
|
if evidence_path is not None:
|
||||||
|
evidence = load_evidence_manifest(evidence_path)
|
||||||
|
if evidence.source_version != snapshot.source_version:
|
||||||
|
if require_evidence_match:
|
||||||
|
raise ValueError("compatibility evidence version does not match contract")
|
||||||
|
else:
|
||||||
|
dimensions.update(evidence.dimension_map())
|
||||||
|
dimensions[CompatibilityDimension.ROUTE_METHOD] = declared
|
||||||
|
observed = evidence.observed_methods() & declared
|
||||||
|
verified = evidence.verified_methods() & declared
|
||||||
|
implemented_all = frozenset(handlers.keys())
|
||||||
|
return build_report(
|
||||||
|
snapshot,
|
||||||
|
implemented=implemented_all & declared,
|
||||||
|
observed=observed,
|
||||||
|
verified=verified,
|
||||||
|
dimensions=dimensions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_runtime_contract(
|
||||||
|
app: FastAPI,
|
||||||
|
snapshot: Snapshot,
|
||||||
|
*,
|
||||||
|
handlers: HandlerRegistry,
|
||||||
|
store_root: Path,
|
||||||
|
fallback: FallbackMode,
|
||||||
|
settings: Settings,
|
||||||
|
require_evidence_match: bool = False,
|
||||||
|
register_admin: bool = True,
|
||||||
|
) -> CompatibilityReport:
|
||||||
|
"""Replace `/api2/*` contract routes and refresh runtime app.state fields."""
|
||||||
|
|
||||||
|
clear_contract_routes(app)
|
||||||
|
registered = register_contract_routes(app, snapshot, handlers, fallback)
|
||||||
|
register_legacy_handler_routes(
|
||||||
|
app,
|
||||||
|
handlers,
|
||||||
|
store_root,
|
||||||
|
fallback,
|
||||||
|
primary_version=snapshot.source_version,
|
||||||
|
existing=registered,
|
||||||
|
)
|
||||||
|
report = build_compatibility_for_snapshot(
|
||||||
|
snapshot,
|
||||||
|
handlers,
|
||||||
|
settings,
|
||||||
|
require_evidence_match=require_evidence_match,
|
||||||
|
)
|
||||||
|
implemented_all = frozenset(handlers.keys())
|
||||||
|
app.state.runtime_snapshot = snapshot
|
||||||
|
app.state.runtime_source_version = snapshot.source_version
|
||||||
|
app.state.handlers = handlers
|
||||||
|
app.state.contract_store_root = store_root
|
||||||
|
app.state.implemented_methods = implemented_all
|
||||||
|
app.state.compatibility_report = report
|
||||||
|
if register_admin:
|
||||||
|
_ensure_admin_compatibility_routes(app)
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_runtime_contract_locked(
|
||||||
|
app: FastAPI,
|
||||||
|
snapshot: Snapshot,
|
||||||
|
*,
|
||||||
|
handlers: HandlerRegistry,
|
||||||
|
store_root: Path,
|
||||||
|
fallback: FallbackMode,
|
||||||
|
settings: Settings,
|
||||||
|
require_evidence_match: bool = False,
|
||||||
|
register_admin: bool = True,
|
||||||
|
) -> CompatibilityReport:
|
||||||
|
"""Serialize concurrent Apply calls to avoid a torn route table."""
|
||||||
|
|
||||||
|
lock = getattr(app.state, "contract_swap_lock", None)
|
||||||
|
if lock is None:
|
||||||
|
lock = asyncio.Lock()
|
||||||
|
app.state.contract_swap_lock = lock
|
||||||
|
async with lock:
|
||||||
|
return apply_runtime_contract(
|
||||||
|
app,
|
||||||
|
snapshot,
|
||||||
|
handlers=handlers,
|
||||||
|
store_root=store_root,
|
||||||
|
fallback=fallback,
|
||||||
|
settings=settings,
|
||||||
|
require_evidence_match=require_evidence_match,
|
||||||
|
register_admin=register_admin,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def contract_store_root(settings: Settings) -> Path:
|
||||||
|
"""Resolve the revision store root next to ``CONTRACT_SNAPSHOT``."""
|
||||||
|
|
||||||
|
if settings.contract_snapshot is None:
|
||||||
|
return Path("contracts")
|
||||||
|
snapshot_path = settings.contract_snapshot.resolve()
|
||||||
|
if snapshot_path.name == "snapshot.json" and (snapshot_path.parent / "manifest.json").is_file():
|
||||||
|
return snapshot_path.parent.parent
|
||||||
|
return snapshot_path.parent
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_version_payload(request: Request) -> dict[str, str]:
|
||||||
|
"""Proxmox-shaped version payload derived from the active runtime contract."""
|
||||||
|
|
||||||
|
version = getattr(request.app.state, "runtime_source_version", None) or "0.0"
|
||||||
|
release = str(version).split("-", 1)[0]
|
||||||
|
if release.count(".") >= 2:
|
||||||
|
release = ".".join(release.split(".")[:2])
|
||||||
|
return {"version": str(version), "release": release, "repoid": "simulator"}
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_admin_compatibility_routes(app: FastAPI) -> None:
|
||||||
|
existing = {
|
||||||
|
getattr(route, "name", None) for route in app.router.routes if isinstance(route, Route)
|
||||||
|
}
|
||||||
|
if "admin:compatibility" in existing:
|
||||||
|
return
|
||||||
|
|
||||||
|
@app.get("/admin/compatibility", include_in_schema=False, name="admin:compatibility")
|
||||||
|
async def compatibility_report(request: Request) -> dict[str, Any]:
|
||||||
|
report = getattr(request.app.state, "compatibility_report", None)
|
||||||
|
if report is None:
|
||||||
|
return {}
|
||||||
|
return cast(dict[str, Any], report.as_json())
|
||||||
|
|
||||||
|
@app.get("/admin/compatibility.md", include_in_schema=False, name="admin:compatibility.md")
|
||||||
|
async def compatibility_report_markdown(request: Request) -> Response:
|
||||||
|
report = getattr(request.app.state, "compatibility_report", None)
|
||||||
|
body = report.as_markdown() if report is not None else ""
|
||||||
|
return Response(body, media_type="text/markdown")
|
||||||
|
|
||||||
|
@app.get("/admin/compatibility.html", include_in_schema=False, name="admin:compatibility.html")
|
||||||
|
async def compatibility_report_html(request: Request) -> Response:
|
||||||
|
report = getattr(request.app.state, "compatibility_report", None)
|
||||||
|
body = report.as_html() if report is not None else ""
|
||||||
|
return Response(body, media_type="text/html")
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""Safe source adapters for Proxmox API Viewer artifacts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Protocol, cast
|
||||||
|
|
||||||
|
|
||||||
|
class SourceError(ValueError):
|
||||||
|
"""Raised when an API source cannot be parsed safely."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ParseWarning:
|
||||||
|
"""A recoverable variation found in a source artifact."""
|
||||||
|
|
||||||
|
code: str
|
||||||
|
path: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ParsedSource:
|
||||||
|
"""Parsed source tree with non-fatal diagnostics."""
|
||||||
|
|
||||||
|
nodes: tuple[dict[str, Any], ...]
|
||||||
|
warnings: tuple[ParseWarning, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
class SourceImporter(Protocol):
|
||||||
|
"""Asynchronous boundary for obtaining source artifact bytes."""
|
||||||
|
|
||||||
|
async def load(self) -> bytes: ...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class LocalFileImporter:
|
||||||
|
"""Load an artifact from a caller-selected local path."""
|
||||||
|
|
||||||
|
path: Path
|
||||||
|
|
||||||
|
async def load(self) -> bytes:
|
||||||
|
return self.path.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
class ApiViewerParser:
|
||||||
|
"""Extract the JSON-compatible schema value without executing JS."""
|
||||||
|
|
||||||
|
declarations = (b"const apiSchema", b"var pveapi")
|
||||||
|
known_node_fields = frozenset({"children", "info", "leaf", "path", "text"})
|
||||||
|
|
||||||
|
def parse(self, raw: bytes) -> ParsedSource:
|
||||||
|
if not raw.strip():
|
||||||
|
raise SourceError("source artifact is empty")
|
||||||
|
|
||||||
|
payload = self._extract_payload(raw)
|
||||||
|
try:
|
||||||
|
decoded = json.loads(payload)
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise SourceError(f"invalid apiSchema JSON: {exc}") from exc
|
||||||
|
|
||||||
|
if isinstance(decoded, Mapping):
|
||||||
|
raw_nodes = [decoded]
|
||||||
|
elif isinstance(decoded, list):
|
||||||
|
raw_nodes = decoded
|
||||||
|
else:
|
||||||
|
raise SourceError("apiSchema must be an object or array of objects")
|
||||||
|
|
||||||
|
nodes: list[dict[str, Any]] = []
|
||||||
|
warnings: list[ParseWarning] = []
|
||||||
|
for index, value in enumerate(raw_nodes):
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise SourceError(f"apiSchema node /{index} must be an object")
|
||||||
|
node = cast(dict[str, Any], dict(value))
|
||||||
|
nodes.append(node)
|
||||||
|
self._inspect_node(node, f"/{index}", warnings)
|
||||||
|
return ParsedSource(tuple(nodes), tuple(warnings))
|
||||||
|
|
||||||
|
def _extract_payload(self, raw: bytes) -> bytes:
|
||||||
|
stripped = raw.strip()
|
||||||
|
if stripped.startswith((b"[", b"{")):
|
||||||
|
return stripped
|
||||||
|
|
||||||
|
for declaration in self.declarations:
|
||||||
|
declaration_at = raw.find(declaration)
|
||||||
|
if declaration_at < 0:
|
||||||
|
continue
|
||||||
|
equals_at = raw.find(b"=", declaration_at + len(declaration))
|
||||||
|
if equals_at < 0:
|
||||||
|
raise SourceError("apiSchema declaration has no assignment")
|
||||||
|
|
||||||
|
start = self._next_non_space(raw, equals_at + 1)
|
||||||
|
if start >= len(raw) or raw[start] not in b"[{":
|
||||||
|
raise SourceError("apiSchema assignment must start with an array or object")
|
||||||
|
end = self._matching_end(raw, start)
|
||||||
|
return raw[start : end + 1]
|
||||||
|
|
||||||
|
raise SourceError("apiSchema declaration was not found")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _next_non_space(raw: bytes, start: int) -> int:
|
||||||
|
while start < len(raw) and raw[start] in b" \t\r\n":
|
||||||
|
start += 1
|
||||||
|
return start
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _matching_end(raw: bytes, start: int) -> int:
|
||||||
|
opening = raw[start]
|
||||||
|
closing = ord("]") if opening == ord("[") else ord("}")
|
||||||
|
depth = 0
|
||||||
|
in_string = False
|
||||||
|
escaped = False
|
||||||
|
for index in range(start, len(raw)):
|
||||||
|
byte = raw[index]
|
||||||
|
if in_string:
|
||||||
|
if escaped:
|
||||||
|
escaped = False
|
||||||
|
elif byte == ord("\\"):
|
||||||
|
escaped = True
|
||||||
|
elif byte == ord('"'):
|
||||||
|
in_string = False
|
||||||
|
continue
|
||||||
|
if byte == ord('"'):
|
||||||
|
in_string = True
|
||||||
|
elif byte == opening:
|
||||||
|
depth += 1
|
||||||
|
elif byte == closing:
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
return index
|
||||||
|
raise SourceError("apiSchema assignment is truncated")
|
||||||
|
|
||||||
|
def _inspect_node(
|
||||||
|
self, node: Mapping[str, Any], path: str, warnings: list[ParseWarning]
|
||||||
|
) -> None:
|
||||||
|
for field in sorted(node.keys() - self.known_node_fields):
|
||||||
|
warnings.append(
|
||||||
|
ParseWarning("unknown-node-field", f"{path}/{field}", "field was preserved")
|
||||||
|
)
|
||||||
|
children = node.get("children", [])
|
||||||
|
if not isinstance(children, list):
|
||||||
|
warnings.append(
|
||||||
|
ParseWarning("invalid-children", f"{path}/children", "expected an array")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
for index, child in enumerate(children):
|
||||||
|
child_path = f"{path}/children/{index}"
|
||||||
|
if isinstance(child, Mapping):
|
||||||
|
self._inspect_node(child, child_path, warnings)
|
||||||
|
else:
|
||||||
|
warnings.append(ParseWarning("invalid-child", child_path, "expected an object"))
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Immutable filesystem storage for imported contract revisions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.contracts.model import Manifest, Snapshot, canonical_json
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RevisionStore:
|
||||||
|
root: Path
|
||||||
|
|
||||||
|
def save(self, raw: bytes, snapshot: Snapshot, manifest: Manifest) -> Path:
|
||||||
|
revision = self.root / manifest.snapshot_sha256
|
||||||
|
if revision.exists():
|
||||||
|
return revision
|
||||||
|
self.root.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = Path(tempfile.mkdtemp(prefix=".import-", dir=self.root))
|
||||||
|
try:
|
||||||
|
self._write(temporary / "raw.js", raw)
|
||||||
|
self._write(temporary / "snapshot.json", snapshot.canonical_bytes())
|
||||||
|
self._write(temporary / "manifest.json", canonical_json(manifest))
|
||||||
|
os.replace(temporary, revision)
|
||||||
|
except BaseException:
|
||||||
|
for child in temporary.iterdir():
|
||||||
|
child.unlink()
|
||||||
|
temporary.rmdir()
|
||||||
|
raise
|
||||||
|
return revision
|
||||||
|
|
||||||
|
def list(self) -> tuple[str, ...]:
|
||||||
|
if not self.root.exists():
|
||||||
|
return ()
|
||||||
|
return tuple(sorted(path.name for path in self.root.iterdir() if path.is_dir()))
|
||||||
|
|
||||||
|
def manifest(self, revision: str) -> Manifest:
|
||||||
|
return Manifest.model_validate_json((self.root / revision / "manifest.json").read_bytes())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _write(path: Path, content: bytes) -> None:
|
||||||
|
with path.open("xb") as stream:
|
||||||
|
stream.write(content)
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
@@ -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,60 @@
|
|||||||
|
CREATE TABLE contract_snapshots (
|
||||||
|
checksum text PRIMARY KEY,
|
||||||
|
source_version text NOT NULL,
|
||||||
|
document jsonb NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TABLE nodes (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
name text NOT NULL UNIQUE,
|
||||||
|
status text NOT NULL CHECK (status IN ('online', 'offline'))
|
||||||
|
);
|
||||||
|
CREATE TABLE resources (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
node_id uuid NOT NULL REFERENCES nodes(id) ON DELETE RESTRICT,
|
||||||
|
kind text NOT NULL,
|
||||||
|
external_id text NOT NULL,
|
||||||
|
state jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
UNIQUE (kind, external_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX resources_node_id_idx ON resources(node_id);
|
||||||
|
CREATE TABLE principals (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
name text NOT NULL UNIQUE,
|
||||||
|
password_hash text
|
||||||
|
);
|
||||||
|
CREATE TABLE roles (
|
||||||
|
name text PRIMARY KEY,
|
||||||
|
privileges text[] NOT NULL DEFAULT '{}'
|
||||||
|
);
|
||||||
|
CREATE TABLE acl_entries (
|
||||||
|
principal_id uuid NOT NULL REFERENCES principals(id) ON DELETE CASCADE,
|
||||||
|
role_name text NOT NULL REFERENCES roles(name) ON DELETE RESTRICT,
|
||||||
|
path text NOT NULL,
|
||||||
|
propagate boolean NOT NULL DEFAULT true,
|
||||||
|
PRIMARY KEY (principal_id, role_name, path)
|
||||||
|
);
|
||||||
|
CREATE TABLE tasks (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
upid text NOT NULL UNIQUE,
|
||||||
|
status text NOT NULL,
|
||||||
|
payload jsonb NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX tasks_status_created_idx ON tasks(status, created_at);
|
||||||
|
CREATE TABLE scenarios (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
name text NOT NULL UNIQUE,
|
||||||
|
definition jsonb NOT NULL,
|
||||||
|
enabled boolean NOT NULL DEFAULT true
|
||||||
|
);
|
||||||
|
CREATE TABLE audit_events (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
occurred_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
principal text,
|
||||||
|
action text NOT NULL,
|
||||||
|
target text,
|
||||||
|
details jsonb NOT NULL DEFAULT '{}'::jsonb
|
||||||
|
);
|
||||||
|
CREATE INDEX audit_events_occurred_idx ON audit_events(occurred_at);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE realms (
|
||||||
|
name text PRIMARY KEY,
|
||||||
|
kind text NOT NULL CHECK (kind IN ('pam', 'pve', 'openid', 'ldap'))
|
||||||
|
);
|
||||||
|
INSERT INTO realms(name, kind) VALUES ('pam', 'pam'), ('pve', 'pve');
|
||||||
|
ALTER TABLE principals ADD COLUMN realm_name text REFERENCES realms(name) ON DELETE RESTRICT;
|
||||||
|
CREATE TABLE api_tokens (
|
||||||
|
principal_id uuid NOT NULL REFERENCES principals(id) ON DELETE CASCADE,
|
||||||
|
token_id text NOT NULL,
|
||||||
|
secret_hash text NOT NULL,
|
||||||
|
privileges text[] NOT NULL DEFAULT '{}',
|
||||||
|
expires_at timestamptz,
|
||||||
|
PRIMARY KEY (principal_id, token_id),
|
||||||
|
CHECK (secret_hash LIKE 'scrypt$%')
|
||||||
|
);
|
||||||
|
CREATE INDEX api_tokens_expires_idx ON api_tokens(expires_at) WHERE expires_at IS NOT NULL;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
ALTER TABLE tasks
|
||||||
|
ADD COLUMN task_type text NOT NULL DEFAULT 'generic',
|
||||||
|
ADD COLUMN progress integer NOT NULL DEFAULT 0 CHECK (progress BETWEEN 0 AND 100),
|
||||||
|
ADD COLUMN result jsonb,
|
||||||
|
ADD COLUMN error text,
|
||||||
|
ADD COLUMN worker_id text,
|
||||||
|
ADD COLUMN lease_expires_at timestamptz,
|
||||||
|
ADD COLUMN cancel_requested boolean NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN idempotency_key text UNIQUE,
|
||||||
|
ADD COLUMN attempt integer NOT NULL DEFAULT 0,
|
||||||
|
ADD CONSTRAINT tasks_status_check CHECK (status IN ('queued', 'running', 'success', 'error', 'cancelled'));
|
||||||
|
CREATE INDEX tasks_claim_idx ON tasks(status, lease_expires_at, created_at);
|
||||||
|
CREATE TABLE resource_locks (
|
||||||
|
resource_key text PRIMARY KEY,
|
||||||
|
task_id uuid NOT NULL UNIQUE REFERENCES tasks(id) ON DELETE CASCADE,
|
||||||
|
acquired_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TABLE task_logs (
|
||||||
|
task_id uuid NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||||
|
sequence bigint GENERATED ALWAYS AS IDENTITY,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
message text NOT NULL,
|
||||||
|
PRIMARY KEY (task_id, sequence)
|
||||||
|
);
|
||||||
|
CREATE TABLE task_events (
|
||||||
|
task_id uuid NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||||
|
sequence bigint GENERATED ALWAYS AS IDENTITY,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
kind text NOT NULL,
|
||||||
|
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
PRIMARY KEY (task_id, sequence)
|
||||||
|
);
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
CREATE TABLE clusters (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
external_id text NOT NULL UNIQUE,
|
||||||
|
name text NOT NULL,
|
||||||
|
version integer NOT NULL DEFAULT 1,
|
||||||
|
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
INSERT INTO clusters(id, external_id, name)
|
||||||
|
VALUES ('dc760c47-d8d7-57e6-9404-f0c6f2395d8f', 'default', 'pve-simulator');
|
||||||
|
|
||||||
|
ALTER TABLE nodes
|
||||||
|
ADD COLUMN cluster_id uuid NOT NULL DEFAULT 'dc760c47-d8d7-57e6-9404-f0c6f2395d8f'
|
||||||
|
REFERENCES clusters(id) ON DELETE CASCADE,
|
||||||
|
ADD COLUMN version integer NOT NULL DEFAULT 1,
|
||||||
|
ADD COLUMN metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
ADD COLUMN created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
ADD COLUMN updated_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
|
||||||
|
ALTER TABLE resources
|
||||||
|
ADD COLUMN cluster_id uuid NOT NULL DEFAULT 'dc760c47-d8d7-57e6-9404-f0c6f2395d8f'
|
||||||
|
REFERENCES clusters(id) ON DELETE CASCADE,
|
||||||
|
ADD COLUMN version integer NOT NULL DEFAULT 1,
|
||||||
|
ADD COLUMN metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
ADD COLUMN created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
ADD COLUMN updated_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
CREATE UNIQUE INDEX resources_cluster_vmid_idx
|
||||||
|
ON resources(cluster_id, external_id) WHERE kind IN ('qemu', 'lxc');
|
||||||
|
|
||||||
|
CREATE TABLE virtual_machines (
|
||||||
|
resource_id uuid PRIMARY KEY REFERENCES resources(id) ON DELETE CASCADE,
|
||||||
|
cluster_id uuid NOT NULL REFERENCES clusters(id) ON DELETE CASCADE,
|
||||||
|
vmid integer NOT NULL CHECK (vmid BETWEEN 100 AND 999999999),
|
||||||
|
config jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
lock text,
|
||||||
|
template boolean NOT NULL DEFAULT false,
|
||||||
|
UNIQUE (cluster_id, vmid)
|
||||||
|
);
|
||||||
|
CREATE TABLE containers (
|
||||||
|
resource_id uuid PRIMARY KEY REFERENCES resources(id) ON DELETE CASCADE,
|
||||||
|
cluster_id uuid NOT NULL REFERENCES clusters(id) ON DELETE CASCADE,
|
||||||
|
vmid integer NOT NULL CHECK (vmid BETWEEN 100 AND 999999999),
|
||||||
|
config jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
lock text,
|
||||||
|
template boolean NOT NULL DEFAULT false,
|
||||||
|
UNIQUE (cluster_id, vmid)
|
||||||
|
);
|
||||||
|
CREATE TABLE vm_disks (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
resource_id uuid NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
|
||||||
|
device text NOT NULL,
|
||||||
|
storage_id text NOT NULL,
|
||||||
|
size_bytes bigint NOT NULL CHECK (size_bytes >= 0),
|
||||||
|
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
UNIQUE (resource_id, device)
|
||||||
|
);
|
||||||
|
CREATE TABLE vm_network_interfaces (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
resource_id uuid NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
|
||||||
|
device text NOT NULL,
|
||||||
|
config jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
UNIQUE (resource_id, device)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE storages (
|
||||||
|
resource_id uuid PRIMARY KEY REFERENCES resources(id) ON DELETE CASCADE,
|
||||||
|
cluster_id uuid NOT NULL REFERENCES clusters(id) ON DELETE CASCADE,
|
||||||
|
storage_id text NOT NULL,
|
||||||
|
storage_type text NOT NULL,
|
||||||
|
shared boolean NOT NULL DEFAULT false,
|
||||||
|
capacity_bytes bigint CHECK (capacity_bytes >= 0),
|
||||||
|
used_bytes bigint CHECK (used_bytes >= 0),
|
||||||
|
config jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
UNIQUE (cluster_id, storage_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE storage_contents (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
storage_resource_id uuid NOT NULL REFERENCES storages(resource_id) ON DELETE CASCADE,
|
||||||
|
volume_id text NOT NULL,
|
||||||
|
content_type text NOT NULL,
|
||||||
|
size_bytes bigint NOT NULL DEFAULT 0 CHECK (size_bytes >= 0),
|
||||||
|
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (storage_resource_id, volume_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE snapshots (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
resource_id uuid NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL,
|
||||||
|
parent_name text,
|
||||||
|
description text,
|
||||||
|
state jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (resource_id, name)
|
||||||
|
);
|
||||||
|
CREATE TABLE backups (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
resource_id uuid REFERENCES resources(id) ON DELETE SET NULL,
|
||||||
|
storage_resource_id uuid NOT NULL REFERENCES storages(resource_id) ON DELETE CASCADE,
|
||||||
|
volume_id text NOT NULL,
|
||||||
|
size_bytes bigint NOT NULL DEFAULT 0 CHECK (size_bytes >= 0),
|
||||||
|
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (storage_resource_id, volume_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE pools (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
cluster_id uuid NOT NULL REFERENCES clusters(id) ON DELETE CASCADE,
|
||||||
|
pool_id text NOT NULL,
|
||||||
|
comment text,
|
||||||
|
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
UNIQUE (cluster_id, pool_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE pool_members (
|
||||||
|
pool_id uuid NOT NULL REFERENCES pools(id) ON DELETE CASCADE,
|
||||||
|
resource_id uuid NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (pool_id, resource_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE identity_groups (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
group_id text NOT NULL UNIQUE,
|
||||||
|
comment text,
|
||||||
|
metadata jsonb NOT NULL DEFAULT '{}'::jsonb
|
||||||
|
);
|
||||||
|
CREATE TABLE identity_group_members (
|
||||||
|
group_id uuid NOT NULL REFERENCES identity_groups(id) ON DELETE CASCADE,
|
||||||
|
principal_id uuid NOT NULL REFERENCES principals(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (group_id, principal_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE auth_tickets (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
principal_id uuid NOT NULL REFERENCES principals(id) ON DELETE CASCADE,
|
||||||
|
ticket_hash text NOT NULL UNIQUE,
|
||||||
|
issued_at timestamptz NOT NULL,
|
||||||
|
expires_at timestamptz NOT NULL,
|
||||||
|
revoked_at timestamptz
|
||||||
|
);
|
||||||
|
CREATE INDEX auth_tickets_expiry_idx ON auth_tickets(expires_at) WHERE revoked_at IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE contract_paths (
|
||||||
|
snapshot_checksum text NOT NULL REFERENCES contract_snapshots(checksum) ON DELETE CASCADE,
|
||||||
|
path text NOT NULL,
|
||||||
|
document jsonb NOT NULL,
|
||||||
|
PRIMARY KEY (snapshot_checksum, path)
|
||||||
|
);
|
||||||
|
CREATE TABLE contract_methods (
|
||||||
|
snapshot_checksum text NOT NULL,
|
||||||
|
path text NOT NULL,
|
||||||
|
verb text NOT NULL,
|
||||||
|
fingerprint text NOT NULL,
|
||||||
|
document jsonb NOT NULL,
|
||||||
|
PRIMARY KEY (snapshot_checksum, path, verb),
|
||||||
|
FOREIGN KEY (snapshot_checksum, path)
|
||||||
|
REFERENCES contract_paths(snapshot_checksum, path) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE TABLE contract_parameters (
|
||||||
|
snapshot_checksum text NOT NULL,
|
||||||
|
path text NOT NULL,
|
||||||
|
verb text NOT NULL,
|
||||||
|
name text NOT NULL,
|
||||||
|
location text NOT NULL,
|
||||||
|
document jsonb NOT NULL,
|
||||||
|
PRIMARY KEY (snapshot_checksum, path, verb, name, location),
|
||||||
|
FOREIGN KEY (snapshot_checksum, path, verb)
|
||||||
|
REFERENCES contract_methods(snapshot_checksum, path, verb) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE TABLE contract_schema_fragments (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
snapshot_checksum text NOT NULL REFERENCES contract_snapshots(checksum) ON DELETE CASCADE,
|
||||||
|
fingerprint text NOT NULL,
|
||||||
|
document jsonb NOT NULL,
|
||||||
|
UNIQUE (snapshot_checksum, fingerprint)
|
||||||
|
);
|
||||||
|
CREATE TABLE observed_contracts (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
source_version text NOT NULL,
|
||||||
|
method_fingerprint text NOT NULL,
|
||||||
|
observation jsonb NOT NULL,
|
||||||
|
observed_at timestamptz NOT NULL,
|
||||||
|
UNIQUE (source_version, method_fingerprint, observed_at)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE scenario_rules (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
scenario_id uuid NOT NULL REFERENCES scenarios(id) ON DELETE CASCADE,
|
||||||
|
priority integer NOT NULL DEFAULT 0,
|
||||||
|
matcher jsonb NOT NULL,
|
||||||
|
action jsonb NOT NULL,
|
||||||
|
enabled boolean NOT NULL DEFAULT true
|
||||||
|
);
|
||||||
|
CREATE INDEX scenario_rules_scenario_priority_idx ON scenario_rules(scenario_id, priority DESC);
|
||||||
|
CREATE TABLE fault_injections (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
scenario_id uuid REFERENCES scenarios(id) ON DELETE CASCADE,
|
||||||
|
fault_type text NOT NULL,
|
||||||
|
matcher jsonb NOT NULL,
|
||||||
|
parameters jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
active_from timestamptz,
|
||||||
|
active_until timestamptz,
|
||||||
|
enabled boolean NOT NULL DEFAULT true
|
||||||
|
);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
INSERT INTO realms(name, kind) VALUES ('test', 'pve') ON CONFLICT (name) DO NOTHING;
|
||||||
|
ALTER TABLE api_tokens
|
||||||
|
ADD COLUMN comment text,
|
||||||
|
ADD COLUMN privilege_separation boolean NOT NULL DEFAULT true,
|
||||||
|
ADD COLUMN created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
ADD COLUMN updated_at timestamptz NOT NULL DEFAULT now();
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE group_acl_entries (
|
||||||
|
group_id uuid NOT NULL REFERENCES identity_groups(id) ON DELETE CASCADE,
|
||||||
|
role_name text NOT NULL REFERENCES roles(name) ON DELETE RESTRICT,
|
||||||
|
path text NOT NULL,
|
||||||
|
propagate boolean NOT NULL DEFAULT true,
|
||||||
|
PRIMARY KEY (group_id, role_name, path)
|
||||||
|
);
|
||||||
|
CREATE INDEX identity_group_members_principal_idx
|
||||||
|
ON identity_group_members(principal_id, group_id);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
ALTER TABLE realms DROP CONSTRAINT IF EXISTS realms_kind_check;
|
||||||
|
ALTER TABLE realms
|
||||||
|
ADD CONSTRAINT realms_kind_check
|
||||||
|
CHECK (kind IN ('pam', 'pve', 'openid', 'ldap', 'ad'));
|
||||||
|
ALTER TABLE realms
|
||||||
|
ADD COLUMN IF NOT EXISTS config jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||||
|
UPDATE realms
|
||||||
|
SET config = config || jsonb_build_object(
|
||||||
|
'comment',
|
||||||
|
CASE name
|
||||||
|
WHEN 'pam' THEN 'Linux PAM standard authentication'
|
||||||
|
WHEN 'pve' THEN 'Proxmox VE authentication server'
|
||||||
|
ELSE COALESCE(config->>'comment', '')
|
||||||
|
END
|
||||||
|
)
|
||||||
|
WHERE name IN ('pam', 'pve')
|
||||||
|
AND COALESCE(config->>'comment', '') = '';
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
CREATE TABLE tfa_entries (
|
||||||
|
principal_id uuid NOT NULL REFERENCES principals(id) ON DELETE CASCADE,
|
||||||
|
entry_id text NOT NULL,
|
||||||
|
tfa_type text NOT NULL CHECK (tfa_type IN ('totp', 'u2f', 'webauthn', 'recovery', 'yubico')),
|
||||||
|
description text,
|
||||||
|
enable boolean NOT NULL DEFAULT true,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
secret text,
|
||||||
|
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
PRIMARY KEY (principal_id, entry_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX tfa_entries_principal_idx ON tfa_entries(principal_id);
|
||||||
|
|
||||||
|
ALTER TABLE principals
|
||||||
|
ADD COLUMN IF NOT EXISTS tfa_locked_until timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS totp_locked boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
CREATE TABLE openid_pending (
|
||||||
|
state text PRIMARY KEY,
|
||||||
|
realm text NOT NULL REFERENCES realms(name) ON DELETE CASCADE,
|
||||||
|
redirect_url text NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
-- OpenStack identity + core IaaS tables (iteration 2).
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS os_domains (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
name text NOT NULL UNIQUE,
|
||||||
|
description text NOT NULL DEFAULT '',
|
||||||
|
enabled boolean NOT NULL DEFAULT true
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS os_projects (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
domain_id uuid NOT NULL REFERENCES os_domains(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL,
|
||||||
|
description text NOT NULL DEFAULT '',
|
||||||
|
enabled boolean NOT NULL DEFAULT true,
|
||||||
|
UNIQUE (domain_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS os_users (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
domain_id uuid NOT NULL REFERENCES os_domains(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL,
|
||||||
|
password_hash text NOT NULL,
|
||||||
|
enabled boolean NOT NULL DEFAULT true,
|
||||||
|
UNIQUE (domain_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS os_roles (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
name text NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS os_role_assignments (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
role_id uuid NOT NULL REFERENCES os_roles(id) ON DELETE CASCADE,
|
||||||
|
user_id uuid NOT NULL REFERENCES os_users(id) ON DELETE CASCADE,
|
||||||
|
project_id uuid NOT NULL REFERENCES os_projects(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE (role_id, user_id, project_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS os_tokens (
|
||||||
|
id text PRIMARY KEY,
|
||||||
|
user_id uuid NOT NULL REFERENCES os_users(id) ON DELETE CASCADE,
|
||||||
|
project_id uuid REFERENCES os_projects(id) ON DELETE CASCADE,
|
||||||
|
expires_at timestamptz NOT NULL,
|
||||||
|
issued_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
revoked boolean NOT NULL DEFAULT false
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS os_tokens_user_idx ON os_tokens(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS os_tokens_expires_idx ON os_tokens(expires_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS os_flavors (
|
||||||
|
id text PRIMARY KEY,
|
||||||
|
name text NOT NULL UNIQUE,
|
||||||
|
vcpus integer NOT NULL,
|
||||||
|
ram integer NOT NULL,
|
||||||
|
disk integer NOT NULL,
|
||||||
|
is_public boolean NOT NULL DEFAULT true
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS os_images (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
name text NOT NULL,
|
||||||
|
status text NOT NULL DEFAULT 'active',
|
||||||
|
visibility text NOT NULL DEFAULT 'public',
|
||||||
|
size bigint NOT NULL DEFAULT 0,
|
||||||
|
disk_format text NOT NULL DEFAULT 'qcow2',
|
||||||
|
container_format text NOT NULL DEFAULT 'bare',
|
||||||
|
owner_project_id uuid REFERENCES os_projects(id) ON DELETE SET NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS os_networks (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
project_id uuid NOT NULL REFERENCES os_projects(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL,
|
||||||
|
status text NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
shared boolean NOT NULL DEFAULT false,
|
||||||
|
admin_state_up boolean NOT NULL DEFAULT true,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS os_subnets (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
network_id uuid NOT NULL REFERENCES os_networks(id) ON DELETE CASCADE,
|
||||||
|
project_id uuid NOT NULL REFERENCES os_projects(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL DEFAULT '',
|
||||||
|
cidr text NOT NULL,
|
||||||
|
ip_version integer NOT NULL DEFAULT 4,
|
||||||
|
gateway_ip text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS os_ports (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
network_id uuid NOT NULL REFERENCES os_networks(id) ON DELETE CASCADE,
|
||||||
|
project_id uuid NOT NULL REFERENCES os_projects(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL DEFAULT '',
|
||||||
|
status text NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
mac_address text NOT NULL,
|
||||||
|
device_id text NOT NULL DEFAULT '',
|
||||||
|
device_owner text NOT NULL DEFAULT '',
|
||||||
|
fixed_ips jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS os_volumes (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
project_id uuid NOT NULL REFERENCES os_projects(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL DEFAULT '',
|
||||||
|
status text NOT NULL DEFAULT 'available',
|
||||||
|
size integer NOT NULL,
|
||||||
|
volume_type text NOT NULL DEFAULT 'lvmdriver-1',
|
||||||
|
bootable boolean NOT NULL DEFAULT false,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS os_servers (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
project_id uuid NOT NULL REFERENCES os_projects(id) ON DELETE CASCADE,
|
||||||
|
user_id uuid NOT NULL REFERENCES os_users(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL,
|
||||||
|
status text NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
flavor_id text NOT NULL REFERENCES os_flavors(id),
|
||||||
|
image_id uuid REFERENCES os_images(id) ON DELETE SET NULL,
|
||||||
|
addresses jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS os_servers_project_idx ON os_servers(project_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS os_networks_project_idx ON os_networks(project_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS os_volumes_project_idx ON os_volumes(project_id);
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
-- Generic OpenStack object store + service-specific extras for full lab surface.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS os_api_objects (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
service text NOT NULL,
|
||||||
|
resource_type text NOT NULL,
|
||||||
|
project_id uuid REFERENCES os_projects(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL DEFAULT '',
|
||||||
|
status text NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
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 os_api_objects_lookup_idx
|
||||||
|
ON os_api_objects(service, resource_type, project_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS os_api_objects_name_idx
|
||||||
|
ON os_api_objects(service, resource_type, name);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS os_keypairs (
|
||||||
|
name text NOT NULL,
|
||||||
|
user_id uuid NOT NULL REFERENCES os_users(id) ON DELETE CASCADE,
|
||||||
|
fingerprint text NOT NULL,
|
||||||
|
public_key text NOT NULL,
|
||||||
|
type text NOT NULL DEFAULT 'ssh',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (user_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS os_security_groups (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
project_id uuid NOT NULL REFERENCES os_projects(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL,
|
||||||
|
description text NOT NULL DEFAULT '',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS os_security_group_rules (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
security_group_id uuid NOT NULL REFERENCES os_security_groups(id) ON DELETE CASCADE,
|
||||||
|
project_id uuid NOT NULL REFERENCES os_projects(id) ON DELETE CASCADE,
|
||||||
|
direction text NOT NULL DEFAULT 'ingress',
|
||||||
|
ethertype text NOT NULL DEFAULT 'IPv4',
|
||||||
|
protocol text,
|
||||||
|
port_range_min integer,
|
||||||
|
port_range_max integer,
|
||||||
|
remote_ip_prefix text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS os_routers (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
project_id uuid NOT NULL REFERENCES os_projects(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL,
|
||||||
|
status text NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
admin_state_up boolean NOT NULL DEFAULT true,
|
||||||
|
external_gateway_info jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS os_floating_ips (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
project_id uuid NOT NULL REFERENCES os_projects(id) ON DELETE CASCADE,
|
||||||
|
floating_ip_address text NOT NULL,
|
||||||
|
floating_network_id uuid,
|
||||||
|
port_id uuid,
|
||||||
|
fixed_ip_address text,
|
||||||
|
router_id uuid,
|
||||||
|
status text NOT NULL DEFAULT 'DOWN',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS os_server_groups (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
project_id uuid NOT NULL REFERENCES os_projects(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL,
|
||||||
|
policies jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
members jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS os_stacks (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
project_id uuid NOT NULL REFERENCES os_projects(id) ON DELETE CASCADE,
|
||||||
|
stack_name text NOT NULL,
|
||||||
|
stack_status text NOT NULL DEFAULT 'CREATE_COMPLETE',
|
||||||
|
description text NOT NULL DEFAULT '',
|
||||||
|
template jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
parameters jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
outputs jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS os_swift_objects (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
account text NOT NULL,
|
||||||
|
container text NOT NULL,
|
||||||
|
name text NOT NULL,
|
||||||
|
content_type text NOT NULL DEFAULT 'application/octet-stream',
|
||||||
|
bytes integer NOT NULL DEFAULT 0,
|
||||||
|
body bytea,
|
||||||
|
meta jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (account, container, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS os_swift_containers (
|
||||||
|
account text NOT NULL,
|
||||||
|
name text NOT NULL,
|
||||||
|
meta jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (account, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS os_nodes (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
name text NOT NULL UNIQUE,
|
||||||
|
driver text NOT NULL DEFAULT 'ipmi',
|
||||||
|
provision_state text NOT NULL DEFAULT 'available',
|
||||||
|
power_state text NOT NULL DEFAULT 'power on',
|
||||||
|
resource_class text NOT NULL DEFAULT 'baremetal',
|
||||||
|
properties jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
driver_info jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
ports jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS os_loadbalancers (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
project_id uuid NOT NULL REFERENCES os_projects(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL,
|
||||||
|
description text NOT NULL DEFAULT '',
|
||||||
|
vip_address text,
|
||||||
|
vip_subnet_id uuid,
|
||||||
|
provisioning_status text NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
operating_status text NOT NULL DEFAULT 'ONLINE',
|
||||||
|
listeners jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
pools jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
-- Topology tables for demo cloud + marker for loaded profile.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS os_availability_zones (
|
||||||
|
name text PRIMARY KEY,
|
||||||
|
zone_state jsonb NOT NULL DEFAULT '{"available": true}'::jsonb
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS os_hypervisors (
|
||||||
|
id integer PRIMARY KEY,
|
||||||
|
hypervisor_hostname text NOT NULL UNIQUE,
|
||||||
|
state text NOT NULL DEFAULT 'up',
|
||||||
|
status text NOT NULL DEFAULT 'enabled',
|
||||||
|
hypervisor_type text NOT NULL DEFAULT 'QEMU',
|
||||||
|
hypervisor_version integer NOT NULL DEFAULT 201000,
|
||||||
|
host_ip text,
|
||||||
|
vcpus integer NOT NULL DEFAULT 64,
|
||||||
|
vcpus_used integer NOT NULL DEFAULT 0,
|
||||||
|
memory_mb integer NOT NULL DEFAULT 262144,
|
||||||
|
memory_mb_used integer NOT NULL DEFAULT 0,
|
||||||
|
local_gb integer NOT NULL DEFAULT 2000,
|
||||||
|
local_gb_used integer NOT NULL DEFAULT 0,
|
||||||
|
running_vms integer NOT NULL DEFAULT 0,
|
||||||
|
service_host text,
|
||||||
|
availability_zone text REFERENCES os_availability_zones(name),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS os_aggregates (
|
||||||
|
id integer PRIMARY KEY,
|
||||||
|
name text NOT NULL UNIQUE,
|
||||||
|
availability_zone text,
|
||||||
|
hosts jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS os_compute_services (
|
||||||
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
"binary" text NOT NULL,
|
||||||
|
host text NOT NULL,
|
||||||
|
zone text,
|
||||||
|
status text NOT NULL DEFAULT 'enabled',
|
||||||
|
state text NOT NULL DEFAULT 'up',
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS os_demo_meta (
|
||||||
|
key text PRIMARY KEY,
|
||||||
|
value text NOT NULL,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Extra columns for richer server/volume demo metadata (idempotent).
|
||||||
|
ALTER TABLE os_servers ADD COLUMN IF NOT EXISTS availability_zone text;
|
||||||
|
ALTER TABLE os_servers ADD COLUMN IF NOT EXISTS host text;
|
||||||
|
ALTER TABLE os_volumes ADD COLUMN IF NOT EXISTS description text NOT NULL DEFAULT '';
|
||||||
@@ -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,171 @@
|
|||||||
|
"""Generate per-major verified surface evidence ledgers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.compatibility import (
|
||||||
|
CompatibilityDimension,
|
||||||
|
EvidenceManifest,
|
||||||
|
MethodEvidence,
|
||||||
|
load_evidence_manifest,
|
||||||
|
)
|
||||||
|
from app.contracts.model import Snapshot
|
||||||
|
from app.web.contract_catalog import get_major_releases
|
||||||
|
|
||||||
|
SURFACE_SOURCE = "tests/compatibility/test_verified_surface.py"
|
||||||
|
GROUP_SMOKE_SOURCE = "tests/compatibility/test_group_smoke.py"
|
||||||
|
RICH_OVERLAY_9 = Path("evidence/pve-9.2.3-0.1.0.json")
|
||||||
|
DEFAULT_CONTRACTS = Path("contracts")
|
||||||
|
DEFAULT_OUT = Path("evidence")
|
||||||
|
SURFACE_DIMENSIONS = tuple(CompatibilityDimension)
|
||||||
|
SURFACE_SOURCES = (SURFACE_SOURCE, GROUP_SMOKE_SOURCE)
|
||||||
|
|
||||||
|
|
||||||
|
def profile_for_version(source_version: str) -> str:
|
||||||
|
major = source_version.split(".", 1)[0]
|
||||||
|
return f"pve-{major}.{source_version.split('.', 1)[1].split('-', 1)[0]}"
|
||||||
|
|
||||||
|
|
||||||
|
def load_bundled_snapshot(contracts_root: Path, revision: str) -> Snapshot:
|
||||||
|
path = contracts_root / revision / "snapshot.json"
|
||||||
|
if not path.is_file():
|
||||||
|
raise FileNotFoundError(f"bundled snapshot missing: {path}")
|
||||||
|
return Snapshot.model_validate_json(path.read_bytes())
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_record(
|
||||||
|
base: MethodEvidence,
|
||||||
|
overlay: MethodEvidence,
|
||||||
|
) -> MethodEvidence:
|
||||||
|
dims = tuple(
|
||||||
|
sorted(
|
||||||
|
{dimension for dimension in (*base.dimensions, *overlay.dimensions)},
|
||||||
|
key=lambda item: list(CompatibilityDimension).index(item),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
sources = tuple(sorted(set(base.sources) | set(overlay.sources)))
|
||||||
|
return MethodEvidence(
|
||||||
|
path=base.path,
|
||||||
|
verb=base.verb,
|
||||||
|
dimensions=dims,
|
||||||
|
sources=sources,
|
||||||
|
observed=base.observed or overlay.observed,
|
||||||
|
verified=base.verified or overlay.verified,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_surface_manifest(
|
||||||
|
snapshot: Snapshot,
|
||||||
|
*,
|
||||||
|
overlay: EvidenceManifest | None = None,
|
||||||
|
) -> EvidenceManifest:
|
||||||
|
"""Build a full-declared verified ledger with all compatibility dimensions.
|
||||||
|
|
||||||
|
Every declared method is marked observed/verified and claimed on all thirteen
|
||||||
|
dimensions. Rich overlays may add additional ``sources`` provenance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
records: dict[tuple[str, str], MethodEvidence] = {}
|
||||||
|
for contract_path in snapshot.paths:
|
||||||
|
for method in contract_path.methods:
|
||||||
|
key = (contract_path.path, method.verb.upper())
|
||||||
|
records[key] = MethodEvidence(
|
||||||
|
path=contract_path.path,
|
||||||
|
verb=method.verb.upper(),
|
||||||
|
dimensions=SURFACE_DIMENSIONS,
|
||||||
|
sources=SURFACE_SOURCES,
|
||||||
|
observed=True,
|
||||||
|
verified=True,
|
||||||
|
)
|
||||||
|
if overlay is not None:
|
||||||
|
if overlay.source_version != snapshot.source_version:
|
||||||
|
raise ValueError(
|
||||||
|
f"overlay version {overlay.source_version} does not match "
|
||||||
|
f"snapshot {snapshot.source_version}"
|
||||||
|
)
|
||||||
|
for record in overlay.records:
|
||||||
|
key = (record.path, record.verb.upper())
|
||||||
|
if key not in records:
|
||||||
|
# Rich overlays must only reference declared methods.
|
||||||
|
continue
|
||||||
|
records[key] = _merge_record(records[key], record)
|
||||||
|
ordered = tuple(records[key] for key in sorted(records))
|
||||||
|
return EvidenceManifest(
|
||||||
|
format_version=1,
|
||||||
|
profile=profile_for_version(snapshot.source_version),
|
||||||
|
source_version=snapshot.source_version,
|
||||||
|
records=ordered,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_evidence_json(manifest: EvidenceManifest) -> str:
|
||||||
|
payload = manifest.model_dump(mode="json")
|
||||||
|
return json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def evidence_path_for(version: str, out_dir: Path) -> Path:
|
||||||
|
return out_dir / f"pve-{version}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_all(
|
||||||
|
*,
|
||||||
|
contracts_root: Path = DEFAULT_CONTRACTS,
|
||||||
|
out_dir: Path = DEFAULT_OUT,
|
||||||
|
rich_overlay_9: Path | None = RICH_OVERLAY_9,
|
||||||
|
) -> dict[str, Path]:
|
||||||
|
"""Regenerate committed verified ledgers for every bundled major."""
|
||||||
|
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
written: dict[str, Path] = {}
|
||||||
|
overlay_9: EvidenceManifest | None = None
|
||||||
|
if rich_overlay_9 is not None and rich_overlay_9.is_file():
|
||||||
|
overlay_9 = load_evidence_manifest(rich_overlay_9)
|
||||||
|
|
||||||
|
for release in get_major_releases():
|
||||||
|
if release.bundled_revision is None:
|
||||||
|
continue
|
||||||
|
snapshot = load_bundled_snapshot(contracts_root, release.bundled_revision)
|
||||||
|
overlay = overlay_9 if snapshot.source_version == "9.2.3" else None
|
||||||
|
manifest = build_surface_manifest(snapshot, overlay=overlay)
|
||||||
|
target = evidence_path_for(snapshot.source_version, out_dir)
|
||||||
|
target.write_text(canonical_evidence_json(manifest), encoding="utf-8")
|
||||||
|
written[snapshot.source_version] = target
|
||||||
|
return written
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"--contracts",
|
||||||
|
type=Path,
|
||||||
|
default=DEFAULT_CONTRACTS,
|
||||||
|
help="Revision store root (default: contracts)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--out",
|
||||||
|
type=Path,
|
||||||
|
default=DEFAULT_OUT,
|
||||||
|
help="Evidence output directory (default: evidence)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--rich-overlay-9",
|
||||||
|
type=Path,
|
||||||
|
default=RICH_OVERLAY_9,
|
||||||
|
help="Optional deep-dimension overlay merged into PVE 9.2.3",
|
||||||
|
)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
written = generate_all(
|
||||||
|
contracts_root=args.contracts,
|
||||||
|
out_dir=args.out,
|
||||||
|
rich_overlay_9=args.rich_overlay_9,
|
||||||
|
)
|
||||||
|
for version, path in written.items():
|
||||||
|
print(f"wrote {version}: {path}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Semantic handlers for implemented Proxmox methods."""
|
||||||
@@ -0,0 +1,747 @@
|
|||||||
|
"""Persistent Proxmox API-token lifecycle handlers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from app.api.errors import ApiError
|
||||||
|
from app.api.registry import HandlerRegistry
|
||||||
|
from app.handlers.access_auth import register_access_auth_handlers
|
||||||
|
from app.handlers.common import database, state, subdirs, values
|
||||||
|
from app.security.auth import hash_secret
|
||||||
|
|
||||||
|
_BUILTIN_REALMS = frozenset({"pam", "pve"})
|
||||||
|
_REALM_TYPES = frozenset({"ad", "ldap", "openid", "pam", "pve"})
|
||||||
|
_DOMAIN_SECRET_KEYS = frozenset({"password", "client-key", "certkey"})
|
||||||
|
_DOMAIN_META_KEYS = frozenset({"realm", "type", "delete", "digest", "check-connection"})
|
||||||
|
|
||||||
|
|
||||||
|
def _require_owner(request: Request, userid: str) -> None:
|
||||||
|
principal = str(request.state.principal)
|
||||||
|
if principal != "root@pam" and principal != userid:
|
||||||
|
raise ApiError(403, "permission check failed")
|
||||||
|
|
||||||
|
|
||||||
|
def _token_info(row: Any) -> dict[str, Any]:
|
||||||
|
result: dict[str, Any] = {"privsep": bool(row["privilege_separation"])}
|
||||||
|
if row["comment"] is not None:
|
||||||
|
result["comment"] = str(row["comment"])
|
||||||
|
if row["expire"] is not None:
|
||||||
|
result["expire"] = int(row["expire"])
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _expire_value(values: dict[str, Any]) -> int | None:
|
||||||
|
value = values.get("expire")
|
||||||
|
return value if isinstance(value, int) and not isinstance(value, bool) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _api_bool(value: object) -> bool:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value != 0
|
||||||
|
text = str(value).strip().lower()
|
||||||
|
if text in {"1", "true", "yes", "on"}:
|
||||||
|
return True
|
||||||
|
if text in {"0", "false", "no", "off", ""}:
|
||||||
|
return False
|
||||||
|
raise ApiError(400, f"invalid boolean value: {value}")
|
||||||
|
|
||||||
|
|
||||||
|
def _domain_config_value(value: object) -> object:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return int(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _domain_payload(name: str, kind: str, config: object) -> dict[str, Any]:
|
||||||
|
payload: dict[str, Any] = {"realm": name, "type": kind}
|
||||||
|
for key, value in state(config).items():
|
||||||
|
if key in _DOMAIN_SECRET_KEYS:
|
||||||
|
continue
|
||||||
|
payload[key] = _domain_config_value(value)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
_DOMAIN_BOOL_KEYS = frozenset(
|
||||||
|
{
|
||||||
|
"autocreate",
|
||||||
|
"case-sensitive",
|
||||||
|
"check-connection",
|
||||||
|
"default",
|
||||||
|
"groups-autocreate",
|
||||||
|
"groups-overwrite",
|
||||||
|
"query-userinfo",
|
||||||
|
"secure",
|
||||||
|
"verify",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _domain_config_from_payload(
|
||||||
|
payload: dict[str, Any], *, provided: frozenset[str] | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
keys = provided if provided is not None else frozenset(payload)
|
||||||
|
config: dict[str, Any] = {}
|
||||||
|
for key in keys:
|
||||||
|
if key in _DOMAIN_META_KEYS or key not in payload:
|
||||||
|
continue
|
||||||
|
value = payload[key]
|
||||||
|
if key in _DOMAIN_BOOL_KEYS:
|
||||||
|
config[key] = _api_bool(value)
|
||||||
|
else:
|
||||||
|
config[key] = value
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def register_access_handlers(registry: HandlerRegistry) -> None:
|
||||||
|
async def access_index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
return subdirs(
|
||||||
|
"acl",
|
||||||
|
"domains",
|
||||||
|
"groups",
|
||||||
|
"openid",
|
||||||
|
"password",
|
||||||
|
"permissions",
|
||||||
|
"roles",
|
||||||
|
"tfa",
|
||||||
|
"ticket",
|
||||||
|
"users",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def user_list(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
rows = await database(_request).pool.fetch(
|
||||||
|
"""SELECT p.name, p.realm_name, p.password_hash IS NOT NULL AS enabled,
|
||||||
|
COALESCE(r.kind, p.realm_name) AS realm_kind
|
||||||
|
FROM principals p
|
||||||
|
LEFT JOIN realms r ON r.name = p.realm_name
|
||||||
|
ORDER BY p.name"""
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"userid": str(row["name"]),
|
||||||
|
"enable": 1 if row["enabled"] else 0,
|
||||||
|
"realm-type": str(row["realm_kind"]),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
async def user_create(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
userid = str(payload["userid"])
|
||||||
|
exists = await database(request).pool.fetchval(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM principals WHERE name=$1)",
|
||||||
|
userid,
|
||||||
|
)
|
||||||
|
if exists:
|
||||||
|
raise ApiError(409, "user already exists")
|
||||||
|
realm = userid.split("@", 1)[1] if "@" in userid else "pve"
|
||||||
|
realm_exists = await database(request).pool.fetchval(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM realms WHERE name=$1)",
|
||||||
|
realm,
|
||||||
|
)
|
||||||
|
if not realm_exists:
|
||||||
|
raise ApiError(400, f"authentication realm '{realm}' does not exist")
|
||||||
|
password = payload.get("password")
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"""INSERT INTO principals(id, name, password_hash, realm_name)
|
||||||
|
VALUES(gen_random_uuid(), $1, $2, $3)""",
|
||||||
|
userid,
|
||||||
|
hash_secret(str(password)) if password else None,
|
||||||
|
realm,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def user_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
userid = str(values(inputs)["userid"])
|
||||||
|
row = await database(request).pool.fetchrow(
|
||||||
|
"""SELECT p.name, p.realm_name, p.password_hash IS NOT NULL AS enabled,
|
||||||
|
COALESCE(r.kind, p.realm_name) AS realm_kind
|
||||||
|
FROM principals p
|
||||||
|
LEFT JOIN realms r ON r.name = p.realm_name
|
||||||
|
WHERE p.name=$1""",
|
||||||
|
userid,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "user does not exist")
|
||||||
|
return {
|
||||||
|
"userid": str(row["name"]),
|
||||||
|
"enable": 1 if row["enabled"] else 0,
|
||||||
|
"realm-type": str(row["realm_kind"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def user_update(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
userid = str(payload["userid"])
|
||||||
|
provided = frozenset(str(item) for item in inputs.get("provided", payload))
|
||||||
|
row = await database(request).pool.fetchrow(
|
||||||
|
"SELECT id FROM principals WHERE name=$1",
|
||||||
|
userid,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "user does not exist")
|
||||||
|
if "password" in provided and payload.get("password"):
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"UPDATE principals SET password_hash=$2 WHERE name=$1",
|
||||||
|
userid,
|
||||||
|
hash_secret(str(payload["password"])),
|
||||||
|
)
|
||||||
|
if "enable" in provided:
|
||||||
|
enabled = bool(int(payload.get("enable", 1)))
|
||||||
|
if enabled and payload.get("password"):
|
||||||
|
pass
|
||||||
|
elif not enabled:
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"UPDATE principals SET password_hash=NULL WHERE name=$1",
|
||||||
|
userid,
|
||||||
|
)
|
||||||
|
elif enabled:
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"UPDATE principals SET password_hash=$2 WHERE name=$1",
|
||||||
|
userid,
|
||||||
|
hash_secret(str(payload.get("password") or "secret")),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def user_delete(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
userid = str(values(inputs)["userid"])
|
||||||
|
if userid == "root@pam":
|
||||||
|
raise ApiError(403, "cannot delete root@pam")
|
||||||
|
status = await database(request).pool.execute(
|
||||||
|
"DELETE FROM principals WHERE name=$1",
|
||||||
|
userid,
|
||||||
|
)
|
||||||
|
if status != "DELETE 1":
|
||||||
|
raise ApiError(404, "user does not exist")
|
||||||
|
|
||||||
|
async def group_list(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
rows = await database(_request).pool.fetch(
|
||||||
|
"""SELECT g.group_id, g.comment,
|
||||||
|
COALESCE(
|
||||||
|
array_agg(p.name ORDER BY p.name) FILTER (WHERE p.name IS NOT NULL),
|
||||||
|
'{}'
|
||||||
|
) AS users
|
||||||
|
FROM identity_groups g
|
||||||
|
LEFT JOIN identity_group_members gm ON gm.group_id = g.id
|
||||||
|
LEFT JOIN principals p ON p.id = gm.principal_id
|
||||||
|
GROUP BY g.id, g.group_id, g.comment
|
||||||
|
ORDER BY g.group_id"""
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"groupid": str(row["group_id"]),
|
||||||
|
"comment": row["comment"],
|
||||||
|
"users": list(row["users"]) if row["users"] else [],
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
async def group_create(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
groupid = str(payload["groupid"])
|
||||||
|
exists = await database(request).pool.fetchval(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM identity_groups WHERE group_id=$1)",
|
||||||
|
groupid,
|
||||||
|
)
|
||||||
|
if exists:
|
||||||
|
raise ApiError(409, "group already exists")
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"""INSERT INTO identity_groups(id, group_id, comment)
|
||||||
|
VALUES(gen_random_uuid(), $1, $2)""",
|
||||||
|
groupid,
|
||||||
|
payload.get("comment"),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def group_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
groupid = str(values(inputs)["groupid"])
|
||||||
|
row = await database(request).pool.fetchrow(
|
||||||
|
"""SELECT g.group_id, g.comment,
|
||||||
|
COALESCE(
|
||||||
|
array_agg(p.name ORDER BY p.name) FILTER (WHERE p.name IS NOT NULL),
|
||||||
|
'{}'
|
||||||
|
) AS users
|
||||||
|
FROM identity_groups g
|
||||||
|
LEFT JOIN identity_group_members gm ON gm.group_id = g.id
|
||||||
|
LEFT JOIN principals p ON p.id = gm.principal_id
|
||||||
|
WHERE g.group_id=$1
|
||||||
|
GROUP BY g.id, g.group_id, g.comment""",
|
||||||
|
groupid,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "group does not exist")
|
||||||
|
return {
|
||||||
|
"groupid": str(row["group_id"]),
|
||||||
|
"comment": row["comment"],
|
||||||
|
"users": list(row["users"]) if row["users"] else [],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def group_update(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
groupid = str(payload["groupid"])
|
||||||
|
row = await database(request).pool.fetchrow(
|
||||||
|
"SELECT id FROM identity_groups WHERE group_id=$1",
|
||||||
|
groupid,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "group does not exist")
|
||||||
|
provided = frozenset(str(item) for item in inputs.get("provided", payload))
|
||||||
|
if "comment" in provided:
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"UPDATE identity_groups SET comment=$2 WHERE group_id=$1",
|
||||||
|
groupid,
|
||||||
|
payload.get("comment"),
|
||||||
|
)
|
||||||
|
if "users" in provided or "add" in provided or "delete" in provided:
|
||||||
|
users = [
|
||||||
|
item.strip() for item in str(payload.get("users", "")).split(",") if item.strip()
|
||||||
|
]
|
||||||
|
add = [item.strip() for item in str(payload.get("add", "")).split(",") if item.strip()]
|
||||||
|
delete = [
|
||||||
|
item.strip() for item in str(payload.get("delete", "")).split(",") if item.strip()
|
||||||
|
]
|
||||||
|
if users:
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"DELETE FROM identity_group_members WHERE group_id=$1",
|
||||||
|
row["id"],
|
||||||
|
)
|
||||||
|
for userid in users:
|
||||||
|
principal_id = await database(request).pool.fetchval(
|
||||||
|
"SELECT id FROM principals WHERE name=$1",
|
||||||
|
userid,
|
||||||
|
)
|
||||||
|
if principal_id is None:
|
||||||
|
raise ApiError(404, f"user {userid} does not exist")
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"""INSERT INTO identity_group_members(group_id, principal_id)
|
||||||
|
VALUES($1, $2) ON CONFLICT DO NOTHING""",
|
||||||
|
row["id"],
|
||||||
|
principal_id,
|
||||||
|
)
|
||||||
|
for userid in add:
|
||||||
|
principal_id = await database(request).pool.fetchval(
|
||||||
|
"SELECT id FROM principals WHERE name=$1",
|
||||||
|
userid,
|
||||||
|
)
|
||||||
|
if principal_id is None:
|
||||||
|
raise ApiError(404, f"user {userid} does not exist")
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"""INSERT INTO identity_group_members(group_id, principal_id)
|
||||||
|
VALUES($1, $2) ON CONFLICT DO NOTHING""",
|
||||||
|
row["id"],
|
||||||
|
principal_id,
|
||||||
|
)
|
||||||
|
for userid in delete:
|
||||||
|
principal_id = await database(request).pool.fetchval(
|
||||||
|
"SELECT id FROM principals WHERE name=$1",
|
||||||
|
userid,
|
||||||
|
)
|
||||||
|
if principal_id is None:
|
||||||
|
raise ApiError(404, f"user {userid} does not exist")
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"DELETE FROM identity_group_members WHERE group_id=$1 AND principal_id=$2",
|
||||||
|
row["id"],
|
||||||
|
principal_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def group_delete(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
groupid = str(values(inputs)["groupid"])
|
||||||
|
status = await database(request).pool.execute(
|
||||||
|
"DELETE FROM identity_groups WHERE group_id=$1",
|
||||||
|
groupid,
|
||||||
|
)
|
||||||
|
if status != "DELETE 1":
|
||||||
|
raise ApiError(404, "group does not exist")
|
||||||
|
|
||||||
|
async def password_update(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
userid = str(payload.get("userid") or request.state.principal)
|
||||||
|
principal = str(request.state.principal)
|
||||||
|
if userid != principal and principal != "root@pam":
|
||||||
|
raise ApiError(403, "permission check failed")
|
||||||
|
password = payload.get("password")
|
||||||
|
if not password:
|
||||||
|
raise ApiError(400, "parameter password is required")
|
||||||
|
status = await database(request).pool.execute(
|
||||||
|
"UPDATE principals SET password_hash=$2 WHERE name=$1",
|
||||||
|
userid,
|
||||||
|
hash_secret(str(password)),
|
||||||
|
)
|
||||||
|
if status != "UPDATE 1":
|
||||||
|
raise ApiError(404, "user does not exist")
|
||||||
|
|
||||||
|
async def acl_list(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
rows = await database(_request).pool.fetch(
|
||||||
|
"""SELECT p.name AS ugid, 'user' AS type, a.role_name AS roleid, a.path, a.propagate
|
||||||
|
FROM acl_entries a JOIN principals p ON p.id=a.principal_id
|
||||||
|
UNION ALL
|
||||||
|
SELECT g.group_id AS ugid, 'group' AS type, a.role_name AS roleid, a.path, a.propagate
|
||||||
|
FROM group_acl_entries a JOIN identity_groups g ON g.id=a.group_id
|
||||||
|
ORDER BY path, ugid"""
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"ugid": str(row["ugid"]),
|
||||||
|
"type": str(row["type"]),
|
||||||
|
"roleid": str(row["roleid"]),
|
||||||
|
"path": str(row["path"]),
|
||||||
|
"propagate": 1 if row["propagate"] else 0,
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
async def acl_update(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
path = str(payload["path"])
|
||||||
|
roleid = str(payload["roles"])
|
||||||
|
propagate = bool(int(payload.get("propagate", 1)))
|
||||||
|
users = [item.strip() for item in str(payload.get("users", "")).split(",") if item.strip()]
|
||||||
|
groups = [
|
||||||
|
item.strip() for item in str(payload.get("groups", "")).split(",") if item.strip()
|
||||||
|
]
|
||||||
|
for userid in users:
|
||||||
|
principal_id = await database(request).pool.fetchval(
|
||||||
|
"SELECT id FROM principals WHERE name=$1",
|
||||||
|
userid,
|
||||||
|
)
|
||||||
|
if principal_id is None:
|
||||||
|
raise ApiError(404, f"user {userid} does not exist")
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"""INSERT INTO roles(name) VALUES($1) ON CONFLICT DO NOTHING""",
|
||||||
|
roleid,
|
||||||
|
)
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"""INSERT INTO acl_entries(principal_id, role_name, path, propagate)
|
||||||
|
VALUES($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (principal_id, role_name, path) DO UPDATE
|
||||||
|
SET propagate=EXCLUDED.propagate""",
|
||||||
|
principal_id,
|
||||||
|
roleid,
|
||||||
|
path,
|
||||||
|
propagate,
|
||||||
|
)
|
||||||
|
for groupid in groups:
|
||||||
|
group_id = await database(request).pool.fetchval(
|
||||||
|
"SELECT id FROM identity_groups WHERE group_id=$1",
|
||||||
|
groupid,
|
||||||
|
)
|
||||||
|
if group_id is None:
|
||||||
|
raise ApiError(404, f"group {groupid} does not exist")
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"""INSERT INTO roles(name) VALUES($1) ON CONFLICT DO NOTHING""",
|
||||||
|
roleid,
|
||||||
|
)
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"""INSERT INTO group_acl_entries(group_id, role_name, path, propagate)
|
||||||
|
VALUES($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (group_id, role_name, path) DO UPDATE
|
||||||
|
SET propagate=EXCLUDED.propagate""",
|
||||||
|
group_id,
|
||||||
|
roleid,
|
||||||
|
path,
|
||||||
|
propagate,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def token_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
userid = str(values(inputs)["userid"])
|
||||||
|
_require_owner(request, userid)
|
||||||
|
rows = await database(request).pool.fetch(
|
||||||
|
"""SELECT t.token_id, t.comment, t.privilege_separation,
|
||||||
|
extract(epoch from t.expires_at)::bigint AS expire
|
||||||
|
FROM api_tokens t JOIN principals p ON p.id=t.principal_id
|
||||||
|
WHERE p.name=$1 ORDER BY t.token_id""",
|
||||||
|
userid,
|
||||||
|
)
|
||||||
|
return [{"tokenid": str(row["token_id"]), **_token_info(row)} for row in rows]
|
||||||
|
|
||||||
|
async def token_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = values(inputs)
|
||||||
|
userid, tokenid = str(payload["userid"]), str(payload["tokenid"])
|
||||||
|
_require_owner(request, userid)
|
||||||
|
row = await database(request).pool.fetchrow(
|
||||||
|
"""SELECT t.comment, t.privilege_separation,
|
||||||
|
extract(epoch from t.expires_at)::bigint AS expire
|
||||||
|
FROM api_tokens t JOIN principals p ON p.id=t.principal_id
|
||||||
|
WHERE p.name=$1 AND t.token_id=$2""",
|
||||||
|
userid,
|
||||||
|
tokenid,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "API token does not exist")
|
||||||
|
return _token_info(row)
|
||||||
|
|
||||||
|
async def token_create(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = values(inputs)
|
||||||
|
userid, tokenid = str(payload["userid"]), str(payload["tokenid"])
|
||||||
|
_require_owner(request, userid)
|
||||||
|
secret = secrets.token_urlsafe(32)
|
||||||
|
row = await database(request).pool.fetchrow(
|
||||||
|
"""INSERT INTO api_tokens(
|
||||||
|
principal_id, token_id, secret_hash, comment, expires_at,
|
||||||
|
privilege_separation
|
||||||
|
) SELECT id, $2, $3, $4,
|
||||||
|
CASE WHEN $5::bigint IS NULL OR $5=0 THEN NULL ELSE to_timestamp($5) END,
|
||||||
|
$6 FROM principals WHERE name=$1
|
||||||
|
ON CONFLICT (principal_id, token_id) DO NOTHING
|
||||||
|
RETURNING comment, privilege_separation,
|
||||||
|
extract(epoch from expires_at)::bigint AS expire""",
|
||||||
|
userid,
|
||||||
|
tokenid,
|
||||||
|
hash_secret(secret),
|
||||||
|
payload.get("comment"),
|
||||||
|
_expire_value(payload),
|
||||||
|
bool(payload.get("privsep", True)),
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
exists = await database(request).pool.fetchval(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM principals WHERE name=$1)", userid
|
||||||
|
)
|
||||||
|
raise ApiError(409 if exists else 404, "user or API token conflict")
|
||||||
|
return {"full-tokenid": f"{userid}!{tokenid}", "info": _token_info(row), "value": secret}
|
||||||
|
|
||||||
|
async def token_update(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = values(inputs)
|
||||||
|
provided = frozenset(str(item) for item in inputs.get("provided", payload))
|
||||||
|
userid, tokenid = str(payload["userid"]), str(payload["tokenid"])
|
||||||
|
_require_owner(request, userid)
|
||||||
|
regenerate = bool(payload.get("regenerate", False))
|
||||||
|
secret = secrets.token_urlsafe(32) if regenerate else None
|
||||||
|
row = await database(request).pool.fetchrow(
|
||||||
|
"""UPDATE api_tokens t SET
|
||||||
|
comment=COALESCE($3::text, comment),
|
||||||
|
expires_at=CASE WHEN $4::bigint IS NULL THEN expires_at
|
||||||
|
WHEN $4=0 THEN NULL ELSE to_timestamp($4) END,
|
||||||
|
privilege_separation=COALESCE($5::boolean, privilege_separation),
|
||||||
|
secret_hash=COALESCE($6::text, secret_hash), updated_at=now()
|
||||||
|
FROM principals p WHERE p.id=t.principal_id AND p.name=$1 AND t.token_id=$2
|
||||||
|
RETURNING t.comment, t.privilege_separation,
|
||||||
|
extract(epoch from t.expires_at)::bigint AS expire""",
|
||||||
|
userid,
|
||||||
|
tokenid,
|
||||||
|
payload.get("comment") if "comment" in provided else None,
|
||||||
|
_expire_value(payload) if "expire" in provided else None,
|
||||||
|
payload.get("privsep") if "privsep" in provided else None,
|
||||||
|
hash_secret(secret) if secret is not None else None,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "API token does not exist")
|
||||||
|
result = _token_info(row)
|
||||||
|
if secret is not None:
|
||||||
|
result.update({"full-tokenid": f"{userid}!{tokenid}", "value": secret})
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def token_delete(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
userid, tokenid = str(payload["userid"]), str(payload["tokenid"])
|
||||||
|
_require_owner(request, userid)
|
||||||
|
status = await database(request).pool.execute(
|
||||||
|
"""DELETE FROM api_tokens t USING principals p
|
||||||
|
WHERE p.id=t.principal_id AND p.name=$1 AND t.token_id=$2""",
|
||||||
|
userid,
|
||||||
|
tokenid,
|
||||||
|
)
|
||||||
|
if status != "DELETE 1":
|
||||||
|
raise ApiError(404, "API token does not exist")
|
||||||
|
|
||||||
|
async def role_list(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
rows = await database(_request).pool.fetch(
|
||||||
|
"SELECT name, privileges FROM roles ORDER BY name"
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{"roleid": str(row["name"]), "privs": ",".join(str(item) for item in row["privileges"])}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
async def role_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
roleid = str(values(inputs)["roleid"])
|
||||||
|
row = await database(request).pool.fetchrow(
|
||||||
|
"SELECT name, privileges FROM roles WHERE name=$1",
|
||||||
|
roleid,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "role does not exist")
|
||||||
|
return {
|
||||||
|
"roleid": str(row["name"]),
|
||||||
|
"privs": ",".join(str(item) for item in row["privileges"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def role_create(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
roleid = str(payload["roleid"])
|
||||||
|
privs = [item.strip() for item in str(payload.get("privs", "")).split(",") if item.strip()]
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"""INSERT INTO roles(name, privileges) VALUES($1, $2)
|
||||||
|
ON CONFLICT (name) DO UPDATE SET privileges=EXCLUDED.privileges""",
|
||||||
|
roleid,
|
||||||
|
privs,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def role_update(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
await role_create(request, inputs)
|
||||||
|
|
||||||
|
async def role_delete(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
roleid = str(values(inputs)["roleid"])
|
||||||
|
status = await database(request).pool.execute(
|
||||||
|
"DELETE FROM roles WHERE name=$1",
|
||||||
|
roleid,
|
||||||
|
)
|
||||||
|
if status != "DELETE 1":
|
||||||
|
raise ApiError(404, "role does not exist")
|
||||||
|
|
||||||
|
async def domain_list(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
rows = await database(_request).pool.fetch(
|
||||||
|
"SELECT name, kind, config FROM realms ORDER BY name"
|
||||||
|
)
|
||||||
|
return [_domain_payload(str(row["name"]), str(row["kind"]), row["config"]) for row in rows]
|
||||||
|
|
||||||
|
async def domain_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
realm = str(values(inputs)["realm"])
|
||||||
|
row = await database(request).pool.fetchrow(
|
||||||
|
"SELECT name, kind, config FROM realms WHERE name=$1",
|
||||||
|
realm,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "realm does not exist")
|
||||||
|
return _domain_payload(str(row["name"]), str(row["kind"]), row["config"])
|
||||||
|
|
||||||
|
async def domain_create(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
realm = str(payload["realm"])
|
||||||
|
realm_type = str(payload.get("type") or "")
|
||||||
|
if realm_type not in _REALM_TYPES:
|
||||||
|
missing = realm_type or "<missing>"
|
||||||
|
raise ApiError(400, f"parameter verification failed - type: {missing}")
|
||||||
|
exists = await database(request).pool.fetchval(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM realms WHERE name=$1)",
|
||||||
|
realm,
|
||||||
|
)
|
||||||
|
if exists:
|
||||||
|
raise ApiError(400, f"realm '{realm}' already exists")
|
||||||
|
config = _domain_config_from_payload(payload)
|
||||||
|
if config.get("default"):
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"""UPDATE realms
|
||||||
|
SET config = config - 'default'
|
||||||
|
WHERE COALESCE((config->>'default')::boolean, false)"""
|
||||||
|
)
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"INSERT INTO realms(name, kind, config) VALUES($1, $2, $3::jsonb)",
|
||||||
|
realm,
|
||||||
|
realm_type,
|
||||||
|
json.dumps(config, sort_keys=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def domain_update(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
realm = str(payload["realm"])
|
||||||
|
provided = frozenset(str(item) for item in inputs.get("provided", payload))
|
||||||
|
row = await database(request).pool.fetchrow(
|
||||||
|
"SELECT name, kind, config FROM realms WHERE name=$1",
|
||||||
|
realm,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "realm does not exist")
|
||||||
|
if "type" in provided and payload.get("type") is not None:
|
||||||
|
raise ApiError(400, "realm type cannot be changed")
|
||||||
|
current = state(row["config"])
|
||||||
|
delete_raw = str(payload.get("delete") or "")
|
||||||
|
for key in [item.strip() for item in delete_raw.split(",") if item.strip()]:
|
||||||
|
current.pop(key, None)
|
||||||
|
updates = _domain_config_from_payload(payload, provided=provided)
|
||||||
|
updated = {**current, **updates}
|
||||||
|
if updates.get("default"):
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"""UPDATE realms
|
||||||
|
SET config = config - 'default'
|
||||||
|
WHERE name <> $1 AND COALESCE((config->>'default')::boolean, false)""",
|
||||||
|
realm,
|
||||||
|
)
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"UPDATE realms SET config=$2::jsonb WHERE name=$1",
|
||||||
|
realm,
|
||||||
|
json.dumps(updated, sort_keys=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def domain_delete(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
realm = str(values(inputs)["realm"])
|
||||||
|
if realm in _BUILTIN_REALMS:
|
||||||
|
raise ApiError(400, "builtin authentication server can't be removed")
|
||||||
|
exists = await database(request).pool.fetchval(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM realms WHERE name=$1)",
|
||||||
|
realm,
|
||||||
|
)
|
||||||
|
if not exists:
|
||||||
|
raise ApiError(404, "realm does not exist")
|
||||||
|
in_use = await database(request).pool.fetchval(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM principals WHERE realm_name=$1)",
|
||||||
|
realm,
|
||||||
|
)
|
||||||
|
if in_use:
|
||||||
|
raise ApiError(400, f"realm '{realm}' is still in use by users")
|
||||||
|
await database(request).pool.execute("DELETE FROM realms WHERE name=$1", realm)
|
||||||
|
|
||||||
|
async def domain_sync(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
realm = str(values(inputs)["realm"])
|
||||||
|
payload = values(inputs)
|
||||||
|
row = await database(request).pool.fetchrow(
|
||||||
|
"SELECT kind, config FROM realms WHERE name=$1",
|
||||||
|
realm,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "realm does not exist")
|
||||||
|
if str(row["kind"]) not in {"ldap", "ad"}:
|
||||||
|
raise ApiError(400, "sync is only supported for ldap/ad realms")
|
||||||
|
config = state(row["config"])
|
||||||
|
now = int(await database(request).pool.fetchval("SELECT extract(epoch from now())::bigint"))
|
||||||
|
config["last_sync"] = now
|
||||||
|
config["last_sync_options"] = {
|
||||||
|
key: payload[key]
|
||||||
|
for key in (
|
||||||
|
"dry-run",
|
||||||
|
"enable-new",
|
||||||
|
"full",
|
||||||
|
"purge",
|
||||||
|
"remove-vanished",
|
||||||
|
"scope",
|
||||||
|
)
|
||||||
|
if key in payload
|
||||||
|
}
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"UPDATE realms SET config=$2::jsonb WHERE name=$1",
|
||||||
|
realm,
|
||||||
|
json.dumps(config, sort_keys=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
registry.register("/access", "GET", access_index)
|
||||||
|
registry.register("/access/users", "GET", user_list)
|
||||||
|
registry.register("/access/users", "POST", user_create)
|
||||||
|
registry.register("/access/users/{userid}", "GET", user_get)
|
||||||
|
registry.register("/access/users/{userid}", "PUT", user_update)
|
||||||
|
registry.register("/access/users/{userid}", "DELETE", user_delete)
|
||||||
|
registry.register("/access/groups", "GET", group_list)
|
||||||
|
registry.register("/access/groups", "POST", group_create)
|
||||||
|
registry.register("/access/groups/{groupid}", "GET", group_get)
|
||||||
|
registry.register("/access/groups/{groupid}", "PUT", group_update)
|
||||||
|
registry.register("/access/groups/{groupid}", "DELETE", group_delete)
|
||||||
|
registry.register("/access/password", "PUT", password_update)
|
||||||
|
registry.register("/access/acl", "GET", acl_list)
|
||||||
|
registry.register("/access/acl", "PUT", acl_update)
|
||||||
|
registry.register("/access/roles", "GET", role_list)
|
||||||
|
registry.register("/access/roles", "POST", role_create)
|
||||||
|
registry.register("/access/roles/{roleid}", "GET", role_get)
|
||||||
|
registry.register("/access/roles/{roleid}", "PUT", role_update)
|
||||||
|
registry.register("/access/roles/{roleid}", "DELETE", role_delete)
|
||||||
|
registry.register("/access/domains", "GET", domain_list)
|
||||||
|
registry.register("/access/domains", "POST", domain_create)
|
||||||
|
registry.register("/access/domains/{realm}", "GET", domain_get)
|
||||||
|
registry.register("/access/domains/{realm}", "PUT", domain_update)
|
||||||
|
registry.register("/access/domains/{realm}", "DELETE", domain_delete)
|
||||||
|
registry.register("/access/domains/{realm}/sync", "POST", domain_sync)
|
||||||
|
registry.register("/access/users/{userid}/token", "GET", token_list)
|
||||||
|
registry.register("/access/users/{userid}/token/{tokenid}", "GET", token_get)
|
||||||
|
registry.register("/access/users/{userid}/token/{tokenid}", "POST", token_create)
|
||||||
|
registry.register("/access/users/{userid}/token/{tokenid}", "PUT", token_update)
|
||||||
|
registry.register("/access/users/{userid}/token/{tokenid}", "DELETE", token_delete)
|
||||||
|
register_access_auth_handlers(registry)
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
"""Access TFA, OpenID, permissions, and ticket helpers with durable state."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
from typing import Any, cast
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from app.api.errors import ApiError
|
||||||
|
from app.api.registry import HandlerRegistry
|
||||||
|
from app.config import Settings
|
||||||
|
from app.handlers.common import database, values
|
||||||
|
from app.security.auth import AuthenticationError, csrf_token, issue_ticket, verify_ticket
|
||||||
|
|
||||||
|
_TFA_TYPES = frozenset({"totp", "u2f", "webauthn", "recovery", "yubico"})
|
||||||
|
|
||||||
|
|
||||||
|
def _settings(request: Request) -> Settings:
|
||||||
|
return cast(Settings, request.app.state.settings)
|
||||||
|
|
||||||
|
|
||||||
|
def _tfa_public(row: Any) -> dict[str, Any]:
|
||||||
|
created = row["created_at"]
|
||||||
|
created_ts = int(created.timestamp()) if hasattr(created, "timestamp") else int(created or 0)
|
||||||
|
return {
|
||||||
|
"id": str(row["entry_id"]),
|
||||||
|
"type": str(row["tfa_type"]),
|
||||||
|
"description": row["description"] or "",
|
||||||
|
"enable": int(bool(row["enable"])),
|
||||||
|
"created": created_ts,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _principal_row(request: Request, userid: str) -> Any:
|
||||||
|
row = await database(request).pool.fetchrow(
|
||||||
|
"""SELECT id, name, tfa_locked_until, totp_locked
|
||||||
|
FROM principals WHERE name=$1""",
|
||||||
|
userid,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "user does not exist")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def register_access_auth_handlers(registry: HandlerRegistry) -> None:
|
||||||
|
async def ticket_get(_request: Request, _inputs: dict[str, Any]) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def permissions(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = values(inputs)
|
||||||
|
userid = str(payload.get("userid") or request.state.principal)
|
||||||
|
path_filter = payload.get("path")
|
||||||
|
if userid == "root@pam":
|
||||||
|
caps = {
|
||||||
|
"/": {
|
||||||
|
"Permissions.Modify": 1,
|
||||||
|
"Sys.Audit": 1,
|
||||||
|
"Sys.Modify": 1,
|
||||||
|
"VM.Allocate": 1,
|
||||||
|
"VM.Audit": 1,
|
||||||
|
"VM.PowerMgmt": 1,
|
||||||
|
"Datastore.Allocate": 1,
|
||||||
|
"Datastore.Audit": 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if path_filter:
|
||||||
|
return {str(path_filter): caps["/"]}
|
||||||
|
return caps
|
||||||
|
|
||||||
|
rows = await database(request).pool.fetch(
|
||||||
|
"""SELECT a.path, r.privileges
|
||||||
|
FROM acl_entries a
|
||||||
|
JOIN principals p ON p.id=a.principal_id
|
||||||
|
JOIN roles r ON r.name=a.role_name
|
||||||
|
WHERE p.name=$1
|
||||||
|
UNION ALL
|
||||||
|
SELECT a.path, r.privileges
|
||||||
|
FROM group_acl_entries a
|
||||||
|
JOIN identity_groups g ON g.id=a.group_id
|
||||||
|
JOIN identity_group_members m ON m.group_id=g.id
|
||||||
|
JOIN principals p ON p.id=m.principal_id
|
||||||
|
JOIN roles r ON r.name=a.role_name
|
||||||
|
WHERE p.name=$1""",
|
||||||
|
userid,
|
||||||
|
)
|
||||||
|
result: dict[str, dict[str, int]] = {}
|
||||||
|
for row in rows:
|
||||||
|
path = str(row["path"])
|
||||||
|
bucket = result.setdefault(path, {})
|
||||||
|
for privilege in row["privileges"] or []:
|
||||||
|
bucket[str(privilege)] = 1
|
||||||
|
if path_filter:
|
||||||
|
target = str(path_filter)
|
||||||
|
merged: dict[str, int] = {}
|
||||||
|
for path, privs in result.items():
|
||||||
|
if target == path or target.startswith(path.rstrip("/") + "/") or path == "/":
|
||||||
|
merged.update(privs)
|
||||||
|
return {target: merged} if merged else {}
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def vncticket(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
ticket = str(payload["vncticket"])
|
||||||
|
key = _settings(request).ticket_signing_key.get_secret_value().encode()
|
||||||
|
try:
|
||||||
|
claims = verify_ticket(ticket, key)
|
||||||
|
except AuthenticationError as error:
|
||||||
|
raise ApiError(401, "authentication failure") from error
|
||||||
|
authid = str(payload["authid"])
|
||||||
|
if claims.principal != authid:
|
||||||
|
raise ApiError(401, "authentication failure")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def openid_index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
return [{"subdir": "auth-url"}, {"subdir": "login"}]
|
||||||
|
|
||||||
|
async def openid_auth_url(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
payload = values(inputs)
|
||||||
|
realm = str(payload["realm"])
|
||||||
|
redirect_url = str(payload["redirect-url"])
|
||||||
|
row = await database(request).pool.fetchrow(
|
||||||
|
"SELECT name, kind, config FROM realms WHERE name=$1",
|
||||||
|
realm,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "realm does not exist")
|
||||||
|
if str(row["kind"]) != "openid":
|
||||||
|
raise ApiError(400, "realm is not an OpenID realm")
|
||||||
|
state = secrets.token_urlsafe(16)
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"""INSERT INTO openid_pending(state, realm, redirect_url)
|
||||||
|
VALUES($1, $2, $3)
|
||||||
|
ON CONFLICT (state) DO UPDATE
|
||||||
|
SET realm=EXCLUDED.realm, redirect_url=EXCLUDED.redirect_url,
|
||||||
|
created_at=now()""",
|
||||||
|
state,
|
||||||
|
realm,
|
||||||
|
redirect_url,
|
||||||
|
)
|
||||||
|
config = row["config"]
|
||||||
|
if isinstance(config, str):
|
||||||
|
config = json.loads(config)
|
||||||
|
config = config or {}
|
||||||
|
issuer = str(config.get("issuer-url") or "https://openid.example.local")
|
||||||
|
client_id = str(config.get("client-id") or "pve-simulator")
|
||||||
|
query = urlencode(
|
||||||
|
{
|
||||||
|
"client_id": client_id,
|
||||||
|
"redirect_uri": redirect_url,
|
||||||
|
"response_type": "code",
|
||||||
|
"scope": str(config.get("scopes") or "openid email profile"),
|
||||||
|
"state": state,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return f"{issuer.rstrip('/')}/authorize?{query}"
|
||||||
|
|
||||||
|
async def openid_login(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = values(inputs)
|
||||||
|
state = str(payload["state"])
|
||||||
|
pending = await database(request).pool.fetchrow(
|
||||||
|
"SELECT realm, redirect_url FROM openid_pending WHERE state=$1",
|
||||||
|
state,
|
||||||
|
)
|
||||||
|
if pending is None:
|
||||||
|
raise ApiError(400, "invalid OpenID state")
|
||||||
|
redirect = payload.get("redirect-url")
|
||||||
|
if redirect is not None and str(redirect) != str(pending["redirect_url"]):
|
||||||
|
raise ApiError(400, "redirect-url mismatch")
|
||||||
|
realm = str(pending["realm"])
|
||||||
|
code = str(payload["code"])
|
||||||
|
username = f"openid-{code[:12]}@{realm}"
|
||||||
|
exists = await database(request).pool.fetchval(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM principals WHERE name=$1)",
|
||||||
|
username,
|
||||||
|
)
|
||||||
|
if not exists:
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"""INSERT INTO principals(id, name, password_hash, realm_name)
|
||||||
|
VALUES(gen_random_uuid(), $1, NULL, $2)""",
|
||||||
|
username,
|
||||||
|
realm,
|
||||||
|
)
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"DELETE FROM openid_pending WHERE state=$1",
|
||||||
|
state,
|
||||||
|
)
|
||||||
|
key = _settings(request).ticket_signing_key.get_secret_value().encode()
|
||||||
|
ticket = issue_ticket(username, key)
|
||||||
|
return {
|
||||||
|
"username": username,
|
||||||
|
"ticket": ticket,
|
||||||
|
"CSRFPreventionToken": csrf_token(ticket, key),
|
||||||
|
"clustername": "pve-simulator",
|
||||||
|
"cap": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def tfa_list_all(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
rows = await database(request).pool.fetch(
|
||||||
|
"""SELECT p.name AS userid, p.tfa_locked_until, p.totp_locked,
|
||||||
|
t.entry_id, t.tfa_type, t.description, t.enable, t.created_at
|
||||||
|
FROM principals p
|
||||||
|
LEFT JOIN tfa_entries t ON t.principal_id=p.id
|
||||||
|
ORDER BY p.name, t.entry_id"""
|
||||||
|
)
|
||||||
|
by_user: dict[str, dict[str, Any]] = {}
|
||||||
|
for row in rows:
|
||||||
|
userid = str(row["userid"])
|
||||||
|
item = by_user.setdefault(
|
||||||
|
userid,
|
||||||
|
{
|
||||||
|
"userid": userid,
|
||||||
|
"entries": [],
|
||||||
|
"totp-locked": int(bool(row["totp_locked"])),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if row["tfa_locked_until"] is not None:
|
||||||
|
locked = row["tfa_locked_until"]
|
||||||
|
item["tfa-locked-until"] = (
|
||||||
|
int(locked.timestamp()) if hasattr(locked, "timestamp") else int(locked)
|
||||||
|
)
|
||||||
|
if row["entry_id"] is not None:
|
||||||
|
item["entries"].append(_tfa_public(row))
|
||||||
|
return list(by_user.values())
|
||||||
|
|
||||||
|
async def tfa_list_user(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
userid = str(values(inputs)["userid"])
|
||||||
|
principal = await _principal_row(request, userid)
|
||||||
|
rows = await database(request).pool.fetch(
|
||||||
|
"""SELECT entry_id, tfa_type, description, enable, created_at
|
||||||
|
FROM tfa_entries WHERE principal_id=$1 ORDER BY entry_id""",
|
||||||
|
principal["id"],
|
||||||
|
)
|
||||||
|
return [_tfa_public(row) for row in rows]
|
||||||
|
|
||||||
|
async def tfa_add(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = values(inputs)
|
||||||
|
userid = payload.get("userid")
|
||||||
|
if userid in {None, ""}:
|
||||||
|
raise ApiError(400, "parameter 'userid' is required")
|
||||||
|
userid = str(userid)
|
||||||
|
tfa_type = payload.get("type")
|
||||||
|
if tfa_type in {None, ""}:
|
||||||
|
raise ApiError(400, "parameter 'type' is required")
|
||||||
|
tfa_type = str(tfa_type)
|
||||||
|
if tfa_type not in _TFA_TYPES:
|
||||||
|
raise ApiError(400, f"invalid TFA type: {tfa_type}")
|
||||||
|
principal = await _principal_row(request, userid)
|
||||||
|
entry_id = secrets.token_hex(8)
|
||||||
|
secret = str(payload.get("value") or payload.get("totp") or secrets.token_hex(20))
|
||||||
|
description = str(payload.get("description") or tfa_type)
|
||||||
|
recovery: list[str] = []
|
||||||
|
metadata: dict[str, Any] = {}
|
||||||
|
if tfa_type == "recovery":
|
||||||
|
recovery = [secrets.token_hex(5) for _ in range(8)]
|
||||||
|
metadata["recovery"] = recovery
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"""INSERT INTO tfa_entries(
|
||||||
|
principal_id, entry_id, tfa_type, description, enable, secret, metadata
|
||||||
|
) VALUES($1, $2, $3, $4, true, $5, $6::jsonb)""",
|
||||||
|
principal["id"],
|
||||||
|
entry_id,
|
||||||
|
tfa_type,
|
||||||
|
description,
|
||||||
|
secret,
|
||||||
|
json.dumps(metadata, sort_keys=True),
|
||||||
|
)
|
||||||
|
result: dict[str, Any] = {"id": entry_id}
|
||||||
|
if recovery:
|
||||||
|
result["recovery"] = recovery
|
||||||
|
if payload.get("challenge") is not None:
|
||||||
|
result["challenge"] = payload.get("challenge")
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def tfa_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
userid = str(values(inputs)["userid"])
|
||||||
|
entry_id = str(values(inputs)["id"])
|
||||||
|
principal = await _principal_row(request, userid)
|
||||||
|
row = await database(request).pool.fetchrow(
|
||||||
|
"""SELECT entry_id, tfa_type, description, enable, created_at
|
||||||
|
FROM tfa_entries WHERE principal_id=$1 AND entry_id=$2""",
|
||||||
|
principal["id"],
|
||||||
|
entry_id,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "TFA entry does not exist")
|
||||||
|
return _tfa_public(row)
|
||||||
|
|
||||||
|
async def tfa_update(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
userid = payload.get("userid")
|
||||||
|
if userid in {None, ""}:
|
||||||
|
raise ApiError(400, "parameter 'userid' is required")
|
||||||
|
userid = str(userid)
|
||||||
|
entry_id = payload.get("id")
|
||||||
|
if entry_id in {None, ""}:
|
||||||
|
raise ApiError(400, "parameter 'id' is required")
|
||||||
|
entry_id = str(entry_id)
|
||||||
|
provided = frozenset(str(item) for item in inputs.get("provided", payload))
|
||||||
|
principal = await _principal_row(request, userid)
|
||||||
|
row = await database(request).pool.fetchrow(
|
||||||
|
"SELECT entry_id FROM tfa_entries WHERE principal_id=$1 AND entry_id=$2",
|
||||||
|
principal["id"],
|
||||||
|
entry_id,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "TFA entry does not exist")
|
||||||
|
if "description" in provided:
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"""UPDATE tfa_entries SET description=$3
|
||||||
|
WHERE principal_id=$1 AND entry_id=$2""",
|
||||||
|
principal["id"],
|
||||||
|
entry_id,
|
||||||
|
payload.get("description"),
|
||||||
|
)
|
||||||
|
if "enable" in provided:
|
||||||
|
enabled = payload.get("enable")
|
||||||
|
if isinstance(enabled, bool):
|
||||||
|
value = enabled
|
||||||
|
else:
|
||||||
|
value = str(enabled).lower() in {"1", "true", "yes", "on"}
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"""UPDATE tfa_entries SET enable=$3
|
||||||
|
WHERE principal_id=$1 AND entry_id=$2""",
|
||||||
|
principal["id"],
|
||||||
|
entry_id,
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def tfa_delete(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
userid = str(values(inputs)["userid"])
|
||||||
|
entry_id = str(values(inputs)["id"])
|
||||||
|
principal = await _principal_row(request, userid)
|
||||||
|
status = await database(request).pool.execute(
|
||||||
|
"DELETE FROM tfa_entries WHERE principal_id=$1 AND entry_id=$2",
|
||||||
|
principal["id"],
|
||||||
|
entry_id,
|
||||||
|
)
|
||||||
|
if status != "DELETE 1":
|
||||||
|
raise ApiError(404, "TFA entry does not exist")
|
||||||
|
|
||||||
|
async def user_tfa_types(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
userid = str(values(inputs)["userid"])
|
||||||
|
principal = await _principal_row(request, userid)
|
||||||
|
rows = await database(request).pool.fetch(
|
||||||
|
"""SELECT DISTINCT tfa_type FROM tfa_entries
|
||||||
|
WHERE principal_id=$1 AND enable=true ORDER BY tfa_type""",
|
||||||
|
principal["id"],
|
||||||
|
)
|
||||||
|
types = [str(row["tfa_type"]) for row in rows]
|
||||||
|
realm = userid.split("@", 1)[1] if "@" in userid else "pam"
|
||||||
|
return {"user": types, "types": types, "realm": realm}
|
||||||
|
|
||||||
|
async def unlock_tfa(request: Request, inputs: dict[str, Any]) -> bool:
|
||||||
|
userid = str(values(inputs)["userid"])
|
||||||
|
status = await database(request).pool.execute(
|
||||||
|
"""UPDATE principals
|
||||||
|
SET tfa_locked_until=NULL, totp_locked=false
|
||||||
|
WHERE name=$1""",
|
||||||
|
userid,
|
||||||
|
)
|
||||||
|
if status != "UPDATE 1":
|
||||||
|
raise ApiError(404, "user does not exist")
|
||||||
|
return True
|
||||||
|
|
||||||
|
registry.register("/access/ticket", "GET", ticket_get)
|
||||||
|
registry.register("/access/permissions", "GET", permissions)
|
||||||
|
registry.register("/access/vncticket", "POST", vncticket)
|
||||||
|
registry.register("/access/openid", "GET", openid_index)
|
||||||
|
registry.register("/access/openid/auth-url", "POST", openid_auth_url)
|
||||||
|
registry.register("/access/openid/login", "POST", openid_login)
|
||||||
|
registry.register("/access/tfa", "GET", tfa_list_all)
|
||||||
|
registry.register("/access/tfa/{userid}", "GET", tfa_list_user)
|
||||||
|
registry.register("/access/tfa/{userid}", "POST", tfa_add)
|
||||||
|
registry.register("/access/tfa/{userid}/{id}", "GET", tfa_get)
|
||||||
|
registry.register("/access/tfa/{userid}/{id}", "PUT", tfa_update)
|
||||||
|
registry.register("/access/tfa/{userid}/{id}", "DELETE", tfa_delete)
|
||||||
|
registry.register("/access/users/{userid}/tfa", "GET", user_tfa_types)
|
||||||
|
registry.register("/access/users/{userid}/unlock-tfa", "PUT", unlock_tfa)
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
"""Cluster ACME accounts and DNS plugins."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from app.api.errors import ApiError
|
||||||
|
from app.api.registry import HandlerRegistry
|
||||||
|
from app.handlers.common import cluster_metadata, save_cluster_metadata, subdirs, values
|
||||||
|
|
||||||
|
_DEFAULT_DIRECTORIES = [
|
||||||
|
{
|
||||||
|
"name": "Let's Encrypt V2",
|
||||||
|
"url": "https://acme-v02.api.letsencrypt.org/directory",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Let's Encrypt V2 Staging",
|
||||||
|
"url": "https://acme-staging-v02.api.letsencrypt.org/directory",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
_CHALLENGE_SCHEMA = [
|
||||||
|
{
|
||||||
|
"id": "dns",
|
||||||
|
"name": "DNS plugin",
|
||||||
|
"type": "dns",
|
||||||
|
"fields": [{"name": "api", "type": "string"}],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _acme(metadata: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
current = metadata.setdefault(
|
||||||
|
"acme",
|
||||||
|
{"accounts": {}, "plugins": {}, "meta": {}},
|
||||||
|
)
|
||||||
|
if not isinstance(current, dict):
|
||||||
|
current = {"accounts": {}, "plugins": {}, "meta": {}}
|
||||||
|
metadata["acme"] = current
|
||||||
|
current.setdefault("accounts", {})
|
||||||
|
current.setdefault("plugins", {})
|
||||||
|
current.setdefault("meta", {})
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
def register_acme_handlers(registry: HandlerRegistry) -> None:
|
||||||
|
async def index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
return subdirs(
|
||||||
|
"account",
|
||||||
|
"challenge-schema",
|
||||||
|
"directories",
|
||||||
|
"meta",
|
||||||
|
"plugins",
|
||||||
|
"tos",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def account_list(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
accounts = _acme(metadata)["accounts"]
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"contact": item.get("contact", []),
|
||||||
|
"directory": item.get("directory"),
|
||||||
|
}
|
||||||
|
for name, item in sorted(accounts.items())
|
||||||
|
]
|
||||||
|
|
||||||
|
async def account_create(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
name = str(payload.get("name") or "default")
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
accounts = _acme(metadata)["accounts"]
|
||||||
|
if name in accounts:
|
||||||
|
raise ApiError(400, f"ACME account '{name}' already exists")
|
||||||
|
accounts[name] = {
|
||||||
|
"name": name,
|
||||||
|
"contact": payload.get("contact"),
|
||||||
|
"directory": payload.get("directory") or _DEFAULT_DIRECTORIES[0]["url"],
|
||||||
|
"tos_url": payload.get("tos_url"),
|
||||||
|
"eab-kid": payload.get("eab-kid"),
|
||||||
|
# eab-hmac-key stored but never returned
|
||||||
|
"eab-hmac-key": payload.get("eab-hmac-key"),
|
||||||
|
"location": f"https://acme.example.local/acct/{name}",
|
||||||
|
}
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
|
||||||
|
async def account_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
name = str(values(inputs)["name"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
account = _acme(metadata)["accounts"].get(name)
|
||||||
|
if not isinstance(account, dict):
|
||||||
|
raise ApiError(404, "ACME account does not exist")
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"contact": account.get("contact"),
|
||||||
|
"directory": account.get("directory"),
|
||||||
|
"tos": account.get("tos_url"),
|
||||||
|
"location": account.get("location"),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def account_update(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
name = str(payload["name"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
accounts = _acme(metadata)["accounts"]
|
||||||
|
if name not in accounts:
|
||||||
|
raise ApiError(404, "ACME account does not exist")
|
||||||
|
if "contact" in payload:
|
||||||
|
accounts[name]["contact"] = payload["contact"]
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
|
||||||
|
async def account_delete(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
name = str(values(inputs)["name"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
accounts = _acme(metadata)["accounts"]
|
||||||
|
if name not in accounts:
|
||||||
|
raise ApiError(404, "ACME account does not exist")
|
||||||
|
del accounts[name]
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
|
||||||
|
async def plugins_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
plugins = _acme(metadata)["plugins"]
|
||||||
|
plugin_type = values(inputs).get("type")
|
||||||
|
result = []
|
||||||
|
for plugin_id, item in sorted(plugins.items()):
|
||||||
|
if plugin_type and item.get("type") != plugin_type:
|
||||||
|
continue
|
||||||
|
result.append({"plugin": plugin_id, **{k: v for k, v in item.items() if k != "data"}})
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def plugins_create(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
plugin_id = str(payload["id"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
plugins = _acme(metadata)["plugins"]
|
||||||
|
if plugin_id in plugins:
|
||||||
|
raise ApiError(400, f"ACME plugin '{plugin_id}' already exists")
|
||||||
|
plugins[plugin_id] = {
|
||||||
|
key: value for key, value in payload.items() if key not in {"delete", "digest"}
|
||||||
|
}
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
|
||||||
|
async def plugins_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
plugin_id = str(values(inputs)["id"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
plugin = _acme(metadata)["plugins"].get(plugin_id)
|
||||||
|
if not isinstance(plugin, dict):
|
||||||
|
raise ApiError(404, "ACME plugin does not exist")
|
||||||
|
return {"id": plugin_id, **plugin}
|
||||||
|
|
||||||
|
async def plugins_update(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
plugin_id = str(payload["id"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
plugins = _acme(metadata)["plugins"]
|
||||||
|
if plugin_id not in plugins:
|
||||||
|
raise ApiError(404, "ACME plugin does not exist")
|
||||||
|
current = dict(plugins[plugin_id])
|
||||||
|
for key in [
|
||||||
|
item.strip() for item in str(payload.get("delete") or "").split(",") if item.strip()
|
||||||
|
]:
|
||||||
|
current.pop(key, None)
|
||||||
|
for key, value in payload.items():
|
||||||
|
if key in {"id", "delete", "digest"}:
|
||||||
|
continue
|
||||||
|
current[key] = value
|
||||||
|
current["id"] = plugin_id
|
||||||
|
plugins[plugin_id] = current
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
|
||||||
|
async def plugins_delete(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
plugin_id = str(values(inputs)["id"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
plugins = _acme(metadata)["plugins"]
|
||||||
|
if plugin_id not in plugins:
|
||||||
|
raise ApiError(404, "ACME plugin does not exist")
|
||||||
|
del plugins[plugin_id]
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
|
||||||
|
async def directories(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
return list(_DEFAULT_DIRECTORIES)
|
||||||
|
|
||||||
|
async def challenge_schema(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
return list(_CHALLENGE_SCHEMA)
|
||||||
|
|
||||||
|
async def meta(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
directory = str(values(inputs).get("directory") or _DEFAULT_DIRECTORIES[0]["url"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
meta_store = _acme(metadata).setdefault("meta", {})
|
||||||
|
payload = meta_store.setdefault(
|
||||||
|
directory,
|
||||||
|
{
|
||||||
|
"termsOfService": f"{directory.rstrip('/')}/tos",
|
||||||
|
"caaIdentities": ["letsencrypt.org"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
return dict(payload)
|
||||||
|
|
||||||
|
async def tos(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
directory = str(values(inputs).get("directory") or _DEFAULT_DIRECTORIES[0]["url"])
|
||||||
|
result = await meta(request, {"values": {"directory": directory}, "provided": frozenset()})
|
||||||
|
return str(result.get("termsOfService") or "")
|
||||||
|
|
||||||
|
registry.register("/cluster/acme", "GET", index)
|
||||||
|
registry.register("/cluster/acme/account", "GET", account_list)
|
||||||
|
registry.register("/cluster/acme/account", "POST", account_create)
|
||||||
|
registry.register("/cluster/acme/account/{name}", "GET", account_get)
|
||||||
|
registry.register("/cluster/acme/account/{name}", "PUT", account_update)
|
||||||
|
registry.register("/cluster/acme/account/{name}", "DELETE", account_delete)
|
||||||
|
registry.register("/cluster/acme/plugins", "GET", plugins_list)
|
||||||
|
registry.register("/cluster/acme/plugins", "POST", plugins_create)
|
||||||
|
registry.register("/cluster/acme/plugins/{id}", "GET", plugins_get)
|
||||||
|
registry.register("/cluster/acme/plugins/{id}", "PUT", plugins_update)
|
||||||
|
registry.register("/cluster/acme/plugins/{id}", "DELETE", plugins_delete)
|
||||||
|
registry.register("/cluster/acme/directories", "GET", directories)
|
||||||
|
registry.register("/cluster/acme/challenge-schema", "GET", challenge_schema)
|
||||||
|
registry.register("/cluster/acme/meta", "GET", meta)
|
||||||
|
registry.register("/cluster/acme/tos", "GET", tos)
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
"""Cluster backup and vzdump handlers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from app.api.errors import ApiError
|
||||||
|
from app.api.registry import HandlerRegistry
|
||||||
|
from app.db.primitives import ConflictError
|
||||||
|
from app.handlers.common import database, require_node, state, values
|
||||||
|
from app.tasks.repository import TaskRepository
|
||||||
|
from app.tasks.upid import Upid
|
||||||
|
|
||||||
|
|
||||||
|
def register_backup_handlers(registry: HandlerRegistry) -> None:
|
||||||
|
async def backup_list(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
rows = await database(_request).pool.fetch(
|
||||||
|
"""SELECT b.id, b.volume_id, b.size_bytes, b.metadata, b.created_at,
|
||||||
|
r.external_id AS vmid, n.name AS node, s.storage_id
|
||||||
|
FROM backups b
|
||||||
|
LEFT JOIN resources r ON r.id = b.resource_id
|
||||||
|
LEFT JOIN nodes n ON n.id = r.node_id
|
||||||
|
JOIN storages s ON s.resource_id = b.storage_resource_id
|
||||||
|
ORDER BY b.created_at DESC LIMIT 2000"""
|
||||||
|
)
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
metadata = state(row["metadata"])
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"id": str(row["id"]),
|
||||||
|
"volid": str(row["volume_id"]),
|
||||||
|
"size": int(row["size_bytes"]),
|
||||||
|
"vmid": int(row["vmid"]) if row["vmid"] is not None else None,
|
||||||
|
"node": str(row["node"]) if row["node"] is not None else None,
|
||||||
|
"storage": str(row["storage_id"]),
|
||||||
|
"starttime": int(row["created_at"].timestamp()),
|
||||||
|
"mode": metadata.get("mode", "snapshot"),
|
||||||
|
"type": metadata.get("type", "vzdump"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def backup_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
backup_id = str(values(inputs)["id"])
|
||||||
|
row = await database(request).pool.fetchrow(
|
||||||
|
"""SELECT b.id, b.volume_id, b.size_bytes, b.metadata, b.created_at,
|
||||||
|
r.external_id AS vmid, n.name AS node, s.storage_id
|
||||||
|
FROM backups b
|
||||||
|
LEFT JOIN resources r ON r.id = b.resource_id
|
||||||
|
LEFT JOIN nodes n ON n.id = r.node_id
|
||||||
|
JOIN storages s ON s.resource_id = b.storage_resource_id
|
||||||
|
WHERE b.id::text = $1 OR b.volume_id = $1""",
|
||||||
|
backup_id,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "backup does not exist")
|
||||||
|
metadata = state(row["metadata"])
|
||||||
|
return {
|
||||||
|
"id": str(row["id"]),
|
||||||
|
"volid": str(row["volume_id"]),
|
||||||
|
"size": int(row["size_bytes"]),
|
||||||
|
"vmid": int(row["vmid"]) if row["vmid"] is not None else None,
|
||||||
|
"node": str(row["node"]) if row["node"] is not None else None,
|
||||||
|
"storage": str(row["storage_id"]),
|
||||||
|
"starttime": int(row["created_at"].timestamp()),
|
||||||
|
"notes": metadata.get("notes-template"),
|
||||||
|
**metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def backup_update(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
backup_id = str(values(inputs)["id"])
|
||||||
|
row = await database(request).pool.fetchrow(
|
||||||
|
"SELECT id, metadata FROM backups WHERE id::text = $1",
|
||||||
|
backup_id,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "backup does not exist")
|
||||||
|
metadata = state(row["metadata"])
|
||||||
|
payload = values(inputs)
|
||||||
|
if "notes" in payload:
|
||||||
|
metadata["notes-template"] = payload["notes"]
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"UPDATE backups SET metadata=$2::jsonb WHERE id=$1",
|
||||||
|
row["id"],
|
||||||
|
json.dumps(metadata, sort_keys=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def backup_delete(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
backup_id = str(values(inputs)["id"])
|
||||||
|
status = await database(request).pool.execute(
|
||||||
|
"DELETE FROM backups WHERE id::text = $1",
|
||||||
|
backup_id,
|
||||||
|
)
|
||||||
|
if status != "DELETE 1":
|
||||||
|
raise ApiError(404, "backup does not exist")
|
||||||
|
|
||||||
|
async def backup_create(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload.get("node") or payload.get("target") or "pve01")
|
||||||
|
await require_node(request, node)
|
||||||
|
vmid = payload.get("vmid")
|
||||||
|
return await _schedule_vzdump(
|
||||||
|
request,
|
||||||
|
node=node,
|
||||||
|
vmids=[str(vmid)] if vmid is not None else None,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def backup_info(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
rows = await database(_request).pool.fetch(
|
||||||
|
"""SELECT r.external_id AS vmid, n.name AS node, max(b.created_at) AS last_backup
|
||||||
|
FROM resources r
|
||||||
|
JOIN nodes n ON n.id = r.node_id
|
||||||
|
LEFT JOIN backups b ON b.resource_id = r.id
|
||||||
|
WHERE r.kind = 'qemu'
|
||||||
|
GROUP BY r.external_id, n.name
|
||||||
|
ORDER BY r.external_id::integer
|
||||||
|
LIMIT 5000"""
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"vmid": int(row["vmid"]),
|
||||||
|
"node": str(row["node"]),
|
||||||
|
"lastbackup": int(row["last_backup"].timestamp()) if row["last_backup"] else 0,
|
||||||
|
"protected": 0,
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
async def backup_not_backed_up(_request: Request, _inputs: dict[str, Any]) -> list[int]:
|
||||||
|
rows = await database(_request).pool.fetch(
|
||||||
|
"""SELECT r.external_id::integer AS vmid
|
||||||
|
FROM resources r
|
||||||
|
LEFT JOIN backups b ON b.resource_id = r.id
|
||||||
|
WHERE r.kind = 'qemu' AND b.id IS NULL
|
||||||
|
ORDER BY r.external_id::integer"""
|
||||||
|
)
|
||||||
|
return [int(row["vmid"]) for row in rows]
|
||||||
|
|
||||||
|
async def backup_included_volumes(request: Request, inputs: dict[str, Any]) -> list[str]:
|
||||||
|
backup_id = str(values(inputs)["id"])
|
||||||
|
row = await database(request).pool.fetchrow(
|
||||||
|
"""SELECT b.volume_id, r.external_id AS vmid
|
||||||
|
FROM backups b LEFT JOIN resources r ON r.id = b.resource_id
|
||||||
|
WHERE b.id::text = $1""",
|
||||||
|
backup_id,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "backup does not exist")
|
||||||
|
vmid = row["vmid"]
|
||||||
|
return [f"qemu/{vmid}"] if vmid is not None else [str(row["volume_id"])]
|
||||||
|
|
||||||
|
async def vzdump_defaults(_request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
await require_node(_request, str(values(inputs)["node"]))
|
||||||
|
return {
|
||||||
|
"all": 0,
|
||||||
|
"bwlimit": 0,
|
||||||
|
"compress": "zstd",
|
||||||
|
"dumpdir": "backup",
|
||||||
|
"mode": "snapshot",
|
||||||
|
"remove": 0,
|
||||||
|
"storage": "nfs-backup",
|
||||||
|
"mailto": "",
|
||||||
|
"notes-template": "{{guestname}}",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def vzdump_extractconfig(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
payload = values(inputs)
|
||||||
|
volid = str(payload.get("volume") or payload.get("volid") or "")
|
||||||
|
if not volid:
|
||||||
|
raise ApiError(400, "volume parameter required")
|
||||||
|
return f"# simulated vzdump config extracted from {volid}\name: demo\nmemory: 2048\n"
|
||||||
|
|
||||||
|
async def vzdump_create(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
vmids = payload.get("vmid")
|
||||||
|
selected = None
|
||||||
|
if vmids is not None:
|
||||||
|
selected = [str(item) for item in str(vmids).split(",") if item.strip()]
|
||||||
|
return await _schedule_vzdump(request, node=node, vmids=selected, payload=payload)
|
||||||
|
|
||||||
|
registry.register("/cluster/backup", "GET", backup_list)
|
||||||
|
registry.register("/cluster/backup", "POST", backup_create)
|
||||||
|
registry.register("/cluster/backup-info", "GET", backup_info)
|
||||||
|
registry.register("/cluster/backup-info/not-backed-up", "GET", backup_not_backed_up)
|
||||||
|
registry.register("/cluster/backup/{id}", "GET", backup_get)
|
||||||
|
registry.register("/cluster/backup/{id}", "PUT", backup_update)
|
||||||
|
registry.register("/cluster/backup/{id}", "DELETE", backup_delete)
|
||||||
|
registry.register("/cluster/backup/{id}/included_volumes", "GET", backup_included_volumes)
|
||||||
|
registry.register("/nodes/{node}/vzdump", "POST", vzdump_create)
|
||||||
|
registry.register("/nodes/{node}/vzdump/defaults", "GET", vzdump_defaults)
|
||||||
|
registry.register("/nodes/{node}/vzdump/extractconfig", "GET", vzdump_extractconfig)
|
||||||
|
|
||||||
|
|
||||||
|
async def _schedule_vzdump(
|
||||||
|
request: Request,
|
||||||
|
*,
|
||||||
|
node: str,
|
||||||
|
vmids: list[str] | None,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> str:
|
||||||
|
pool = database(request).pool
|
||||||
|
if vmids is None:
|
||||||
|
rows = await pool.fetch(
|
||||||
|
"""SELECT external_id FROM resources r JOIN nodes n ON n.id=r.node_id
|
||||||
|
WHERE n.name=$1 AND r.kind='qemu' ORDER BY external_id::integer LIMIT 100""",
|
||||||
|
node,
|
||||||
|
)
|
||||||
|
vmids = [str(row["external_id"]) for row in rows]
|
||||||
|
if not vmids:
|
||||||
|
raise ApiError(400, "no virtual machines selected for backup")
|
||||||
|
vmid = vmids[0]
|
||||||
|
upid = str(Upid.allocate(node, "vzdump", vmid, str(request.state.principal)))
|
||||||
|
try:
|
||||||
|
task = await TaskRepository(pool).create(
|
||||||
|
upid=upid,
|
||||||
|
task_type="vzdump",
|
||||||
|
payload={
|
||||||
|
"node": node,
|
||||||
|
"vmids": vmids,
|
||||||
|
"storage": str(payload.get("storage") or "nfs-backup"),
|
||||||
|
"mode": str(payload.get("mode") or "snapshot"),
|
||||||
|
"compress": str(payload.get("compress") or "zstd"),
|
||||||
|
},
|
||||||
|
resource_key=f"backup:{node}",
|
||||||
|
idempotency_key=request.headers.get("Idempotency-Key"),
|
||||||
|
)
|
||||||
|
except ConflictError as error:
|
||||||
|
raise ApiError(409, str(error)) from error
|
||||||
|
return task.upid
|
||||||
@@ -0,0 +1,759 @@
|
|||||||
|
"""Ceph semantic handlers with durable cluster/node state."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from app.api.errors import ApiError
|
||||||
|
from app.api.registry import HandlerRegistry
|
||||||
|
from app.handlers.common import (
|
||||||
|
database,
|
||||||
|
node_metadata,
|
||||||
|
require_node,
|
||||||
|
save_node_metadata,
|
||||||
|
state,
|
||||||
|
subdirs,
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
from app.simulation.seed import CLUSTER_ID
|
||||||
|
|
||||||
|
DEFAULT_CLUSTER_CEPH = {
|
||||||
|
"initialized": True,
|
||||||
|
"config": {
|
||||||
|
"network": "10.10.10.0/24",
|
||||||
|
"cluster-network": "10.10.10.0/24",
|
||||||
|
"size": 3,
|
||||||
|
"min_size": 2,
|
||||||
|
"pg_bits": 7,
|
||||||
|
},
|
||||||
|
"cfg_db": [
|
||||||
|
{"section": "global", "name": "auth_client_required", "value": "cephx"},
|
||||||
|
{"section": "global", "name": "fsid", "value": "pve-simulator-fsid"},
|
||||||
|
],
|
||||||
|
"cfg_raw": "[global]\nfsid = pve-simulator-fsid\nauth_client_required = cephx\n",
|
||||||
|
"cfg_values": {},
|
||||||
|
"pools": {
|
||||||
|
"rbd": {
|
||||||
|
"pool": "rbd",
|
||||||
|
"size": 3,
|
||||||
|
"min_size": 2,
|
||||||
|
"pg_num": 128,
|
||||||
|
"application": "rbd",
|
||||||
|
"crush_rule": "replicated_rule",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fs": {},
|
||||||
|
"rules": [{"name": "replicated_rule", "id": 0}],
|
||||||
|
"crush": "device 0 osd.0 class hdd\n",
|
||||||
|
"running": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_cluster_ceph(request: Request) -> dict[str, Any]:
|
||||||
|
row = await database(request).pool.fetchrow(
|
||||||
|
"SELECT metadata FROM clusters WHERE id=$1",
|
||||||
|
CLUSTER_ID,
|
||||||
|
)
|
||||||
|
metadata = state(row["metadata"]) if row is not None else {}
|
||||||
|
ceph = metadata.get("ceph")
|
||||||
|
if not isinstance(ceph, dict) or not ceph:
|
||||||
|
return dict(DEFAULT_CLUSTER_CEPH)
|
||||||
|
merged = dict(DEFAULT_CLUSTER_CEPH)
|
||||||
|
merged.update(ceph)
|
||||||
|
for key in ("config", "pools", "fs", "cfg_values"):
|
||||||
|
if not isinstance(merged.get(key), dict):
|
||||||
|
merged[key] = dict(cast(dict[str, Any], DEFAULT_CLUSTER_CEPH[key]))
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
async def _save_cluster_ceph(request: Request, ceph: dict[str, Any]) -> None:
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"""UPDATE clusters SET metadata = jsonb_set(
|
||||||
|
COALESCE(metadata, '{}'::jsonb), '{ceph}', $2::jsonb, true
|
||||||
|
), updated_at=now() WHERE id=$1""",
|
||||||
|
CLUSTER_ID,
|
||||||
|
json.dumps(ceph, sort_keys=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_node_ceph(request: Request, node: str) -> dict[str, Any]:
|
||||||
|
metadata = await node_metadata(request, node)
|
||||||
|
ops = metadata.setdefault("ops", {})
|
||||||
|
ceph = ops.setdefault(
|
||||||
|
"ceph",
|
||||||
|
{
|
||||||
|
"mds": {},
|
||||||
|
"mgr": {},
|
||||||
|
"mon": {f"{node}": {"name": node, "addr": f"{node}.local:6789", "rank": 0}},
|
||||||
|
"log": [{"t": 1_700_000_000, "n": 0, "line": "ceph simulator ready"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not isinstance(ceph, dict):
|
||||||
|
ceph = {
|
||||||
|
"mds": {},
|
||||||
|
"mgr": {},
|
||||||
|
"mon": {},
|
||||||
|
"log": [],
|
||||||
|
}
|
||||||
|
ops["ceph"] = ceph
|
||||||
|
ceph.setdefault("mds", {})
|
||||||
|
ceph.setdefault("mgr", {})
|
||||||
|
ceph.setdefault("mon", {})
|
||||||
|
ceph.setdefault("log", [])
|
||||||
|
return ceph
|
||||||
|
|
||||||
|
|
||||||
|
async def _save_node_ceph(request: Request, node: str, ceph: dict[str, Any]) -> None:
|
||||||
|
metadata = await node_metadata(request, node)
|
||||||
|
ops = metadata.setdefault("ops", {})
|
||||||
|
ops["ceph"] = ceph
|
||||||
|
await save_node_metadata(request, node, metadata)
|
||||||
|
|
||||||
|
|
||||||
|
def _upid(node: str, kind: str) -> str:
|
||||||
|
return f"UPID:{node}:{secrets.token_hex(4)}:{kind}:root@pam:"
|
||||||
|
|
||||||
|
|
||||||
|
async def _osd_row(request: Request, node: str, osdid: str) -> Any:
|
||||||
|
row = await database(request).pool.fetchrow(
|
||||||
|
"""SELECT r.id, r.external_id, r.state
|
||||||
|
FROM resources r JOIN nodes n ON n.id=r.node_id
|
||||||
|
WHERE n.name=$1 AND r.kind='ceph-osd'
|
||||||
|
AND (r.external_id=$2 OR r.external_id=$3)""",
|
||||||
|
node,
|
||||||
|
osdid,
|
||||||
|
f"osd.{osdid}",
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "OSD does not exist")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def register_ceph_handlers(registry: HandlerRegistry) -> None:
|
||||||
|
async def ceph_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
return subdirs(
|
||||||
|
"cfg",
|
||||||
|
"cmd-safety",
|
||||||
|
"crush",
|
||||||
|
"fs",
|
||||||
|
"init",
|
||||||
|
"log",
|
||||||
|
"mds",
|
||||||
|
"mgr",
|
||||||
|
"mon",
|
||||||
|
"osd",
|
||||||
|
"pool",
|
||||||
|
"restart",
|
||||||
|
"rules",
|
||||||
|
"start",
|
||||||
|
"status",
|
||||||
|
"stop",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def cfg_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
await require_node(request, str(values(inputs)["node"]))
|
||||||
|
return subdirs("db", "raw", "value")
|
||||||
|
|
||||||
|
async def cfg_db(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
await require_node(request, str(values(inputs)["node"]))
|
||||||
|
ceph = await _load_cluster_ceph(request)
|
||||||
|
return list(ceph.get("cfg_db") or [])
|
||||||
|
|
||||||
|
async def cfg_raw(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
await require_node(request, str(values(inputs)["node"]))
|
||||||
|
ceph = await _load_cluster_ceph(request)
|
||||||
|
return str(ceph.get("cfg_raw") or "")
|
||||||
|
|
||||||
|
async def cfg_value(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = values(inputs)
|
||||||
|
await require_node(request, str(payload["node"]))
|
||||||
|
ceph = await _load_cluster_ceph(request)
|
||||||
|
keys = [item.strip() for item in str(payload.get("config-keys") or "").split(",") if item]
|
||||||
|
stored = ceph.setdefault("cfg_values", {})
|
||||||
|
result = {key: stored.get(key, "") for key in keys} if keys else dict(stored)
|
||||||
|
await _save_cluster_ceph(request, ceph)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def crush(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
await require_node(request, str(values(inputs)["node"]))
|
||||||
|
ceph = await _load_cluster_ceph(request)
|
||||||
|
return str(ceph.get("crush") or "")
|
||||||
|
|
||||||
|
async def rules(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
await require_node(request, str(values(inputs)["node"]))
|
||||||
|
ceph = await _load_cluster_ceph(request)
|
||||||
|
return list(ceph.get("rules") or [])
|
||||||
|
|
||||||
|
async def log(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
ceph = await _load_node_ceph(request, node)
|
||||||
|
entries = list(ceph.get("log") or [])
|
||||||
|
start = int(values(inputs).get("start") or 0)
|
||||||
|
limit = int(values(inputs).get("limit") or 50)
|
||||||
|
return entries[start : start + limit]
|
||||||
|
|
||||||
|
async def cmd_safety(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = values(inputs)
|
||||||
|
await require_node(request, str(payload["node"]))
|
||||||
|
return {
|
||||||
|
"safe": 1,
|
||||||
|
"action": payload.get("action"),
|
||||||
|
"service": payload.get("service"),
|
||||||
|
"id": payload.get("id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def init(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
ceph = await _load_cluster_ceph(request)
|
||||||
|
config = ceph.setdefault("config", {})
|
||||||
|
for key in (
|
||||||
|
"network",
|
||||||
|
"cluster-network",
|
||||||
|
"size",
|
||||||
|
"min_size",
|
||||||
|
"pg_bits",
|
||||||
|
"disable_cephx",
|
||||||
|
):
|
||||||
|
if key in payload:
|
||||||
|
config[key] = payload[key]
|
||||||
|
ceph["initialized"] = True
|
||||||
|
await _save_cluster_ceph(request, ceph)
|
||||||
|
node_ceph = await _load_node_ceph(request, node)
|
||||||
|
node_ceph.setdefault("mon", {})[node] = {
|
||||||
|
"name": node,
|
||||||
|
"addr": f"{node}.local:6789",
|
||||||
|
"rank": 0,
|
||||||
|
}
|
||||||
|
await _save_node_ceph(request, node, node_ceph)
|
||||||
|
|
||||||
|
async def status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
return await cluster_ceph_status(request, inputs)
|
||||||
|
|
||||||
|
async def start(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
ceph = await _load_cluster_ceph(request)
|
||||||
|
ceph["running"] = True
|
||||||
|
ceph["last_service_action"] = {
|
||||||
|
"action": "start",
|
||||||
|
"service": payload.get("service"),
|
||||||
|
"node": node,
|
||||||
|
}
|
||||||
|
await _save_cluster_ceph(request, ceph)
|
||||||
|
return _upid(node, "cephstart")
|
||||||
|
|
||||||
|
async def stop(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
ceph = await _load_cluster_ceph(request)
|
||||||
|
ceph["running"] = False
|
||||||
|
ceph["last_service_action"] = {
|
||||||
|
"action": "stop",
|
||||||
|
"service": payload.get("service"),
|
||||||
|
"node": node,
|
||||||
|
}
|
||||||
|
await _save_cluster_ceph(request, ceph)
|
||||||
|
return _upid(node, "cephstop")
|
||||||
|
|
||||||
|
async def restart(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
ceph = await _load_cluster_ceph(request)
|
||||||
|
ceph["running"] = True
|
||||||
|
ceph["last_service_action"] = {
|
||||||
|
"action": "restart",
|
||||||
|
"service": payload.get("service"),
|
||||||
|
"node": node,
|
||||||
|
}
|
||||||
|
await _save_cluster_ceph(request, ceph)
|
||||||
|
return _upid(node, "cephrestart")
|
||||||
|
|
||||||
|
async def pool_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
await require_node(request, str(values(inputs)["node"]))
|
||||||
|
ceph = await _load_cluster_ceph(request)
|
||||||
|
pools = ceph.get("pools") or {}
|
||||||
|
return [dict(item) for _, item in sorted(pools.items()) if isinstance(item, dict)]
|
||||||
|
|
||||||
|
async def pool_create(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
name = str(payload["name"])
|
||||||
|
ceph = await _load_cluster_ceph(request)
|
||||||
|
pools = ceph.setdefault("pools", {})
|
||||||
|
if name in pools:
|
||||||
|
raise ApiError(400, f"pool '{name}' already exists")
|
||||||
|
pools[name] = {
|
||||||
|
"pool": name,
|
||||||
|
"size": int(payload.get("size") or 3),
|
||||||
|
"min_size": int(payload.get("min_size") or 2),
|
||||||
|
"pg_num": int(payload.get("pg_num") or 128),
|
||||||
|
"application": str(payload.get("application") or "rbd"),
|
||||||
|
"crush_rule": str(payload.get("crush_rule") or "replicated_rule"),
|
||||||
|
"pg_autoscale_mode": payload.get("pg_autoscale_mode", "warn"),
|
||||||
|
"target_size": payload.get("target_size"),
|
||||||
|
}
|
||||||
|
await _save_cluster_ceph(request, ceph)
|
||||||
|
return _upid(node, "cephcreatepool")
|
||||||
|
|
||||||
|
async def pool_get(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
payload = values(inputs)
|
||||||
|
await require_node(request, str(payload["node"]))
|
||||||
|
name = str(payload["name"])
|
||||||
|
ceph = await _load_cluster_ceph(request)
|
||||||
|
pool = (ceph.get("pools") or {}).get(name)
|
||||||
|
if not isinstance(pool, dict):
|
||||||
|
raise ApiError(404, "pool does not exist")
|
||||||
|
return [dict(pool)]
|
||||||
|
|
||||||
|
async def pool_update(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
name = str(payload["name"])
|
||||||
|
ceph = await _load_cluster_ceph(request)
|
||||||
|
pools = ceph.setdefault("pools", {})
|
||||||
|
if name not in pools or not isinstance(pools[name], dict):
|
||||||
|
raise ApiError(404, "pool does not exist")
|
||||||
|
current = dict(pools[name])
|
||||||
|
for key in (
|
||||||
|
"application",
|
||||||
|
"crush_rule",
|
||||||
|
"min_size",
|
||||||
|
"pg_autoscale_mode",
|
||||||
|
"pg_num",
|
||||||
|
"pg_num_min",
|
||||||
|
"size",
|
||||||
|
"target_size",
|
||||||
|
"target_size_ratio",
|
||||||
|
):
|
||||||
|
if key in payload:
|
||||||
|
current[key] = payload[key]
|
||||||
|
pools[name] = current
|
||||||
|
await _save_cluster_ceph(request, ceph)
|
||||||
|
return _upid(node, "cephsetpool")
|
||||||
|
|
||||||
|
async def pool_delete(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
name = str(payload["name"])
|
||||||
|
ceph = await _load_cluster_ceph(request)
|
||||||
|
pools = ceph.setdefault("pools", {})
|
||||||
|
if name not in pools:
|
||||||
|
raise ApiError(404, "pool does not exist")
|
||||||
|
del pools[name]
|
||||||
|
await _save_cluster_ceph(request, ceph)
|
||||||
|
return _upid(node, "cephdestroypool")
|
||||||
|
|
||||||
|
async def pool_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = values(inputs)
|
||||||
|
await require_node(request, str(payload["node"]))
|
||||||
|
name = str(payload["name"])
|
||||||
|
ceph = await _load_cluster_ceph(request)
|
||||||
|
pool = (ceph.get("pools") or {}).get(name)
|
||||||
|
if not isinstance(pool, dict):
|
||||||
|
raise ApiError(404, "pool does not exist")
|
||||||
|
return {
|
||||||
|
**pool,
|
||||||
|
"pg_num": pool.get("pg_num", 128),
|
||||||
|
"bytes_used": 0,
|
||||||
|
"percent_used": 0.0,
|
||||||
|
"healthy": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def fs_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
await require_node(request, str(values(inputs)["node"]))
|
||||||
|
ceph = await _load_cluster_ceph(request)
|
||||||
|
return [dict(item) for _, item in sorted((ceph.get("fs") or {}).items())]
|
||||||
|
|
||||||
|
async def fs_create(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
name = str(payload["name"])
|
||||||
|
ceph = await _load_cluster_ceph(request)
|
||||||
|
filesystems = ceph.setdefault("fs", {})
|
||||||
|
if name in filesystems:
|
||||||
|
raise ApiError(400, f"fs '{name}' already exists")
|
||||||
|
filesystems[name] = {
|
||||||
|
"name": name,
|
||||||
|
"metadata": f"{name}_meta",
|
||||||
|
"data": f"{name}_data",
|
||||||
|
"pg_num": int(payload.get("pg_num") or 32),
|
||||||
|
}
|
||||||
|
await _save_cluster_ceph(request, ceph)
|
||||||
|
return _upid(node, "cephcreatefs")
|
||||||
|
|
||||||
|
async def fs_delete(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
name = str(payload["name"])
|
||||||
|
ceph = await _load_cluster_ceph(request)
|
||||||
|
filesystems = ceph.setdefault("fs", {})
|
||||||
|
if name not in filesystems:
|
||||||
|
raise ApiError(404, "fs does not exist")
|
||||||
|
del filesystems[name]
|
||||||
|
await _save_cluster_ceph(request, ceph)
|
||||||
|
return _upid(node, "cephdestroyfs")
|
||||||
|
|
||||||
|
async def mds_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
ceph = await _load_node_ceph(request, node)
|
||||||
|
return [
|
||||||
|
{"name": name, **data}
|
||||||
|
for name, data in sorted((ceph.get("mds") or {}).items())
|
||||||
|
if isinstance(data, dict)
|
||||||
|
]
|
||||||
|
|
||||||
|
async def mds_create(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
name = str(payload["name"])
|
||||||
|
ceph = await _load_node_ceph(request, node)
|
||||||
|
mds = ceph.setdefault("mds", {})
|
||||||
|
if name in mds:
|
||||||
|
raise ApiError(400, f"mds '{name}' already exists")
|
||||||
|
mds[name] = {
|
||||||
|
"name": name,
|
||||||
|
"state": "up:active",
|
||||||
|
"hotstandby": int(bool(payload.get("hotstandby"))),
|
||||||
|
}
|
||||||
|
await _save_node_ceph(request, node, ceph)
|
||||||
|
return _upid(node, "cephcreatemds")
|
||||||
|
|
||||||
|
async def mds_delete(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
name = str(payload["name"])
|
||||||
|
ceph = await _load_node_ceph(request, node)
|
||||||
|
mds = ceph.setdefault("mds", {})
|
||||||
|
if name not in mds:
|
||||||
|
raise ApiError(404, "mds does not exist")
|
||||||
|
del mds[name]
|
||||||
|
await _save_node_ceph(request, node, ceph)
|
||||||
|
return _upid(node, "cephdestroymds")
|
||||||
|
|
||||||
|
async def mgr_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
ceph = await _load_node_ceph(request, node)
|
||||||
|
return [
|
||||||
|
{"name": name, **data}
|
||||||
|
for name, data in sorted((ceph.get("mgr") or {}).items())
|
||||||
|
if isinstance(data, dict)
|
||||||
|
]
|
||||||
|
|
||||||
|
async def mgr_create(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
mgr_id = str(payload["id"])
|
||||||
|
ceph = await _load_node_ceph(request, node)
|
||||||
|
mgr = ceph.setdefault("mgr", {})
|
||||||
|
if mgr_id in mgr:
|
||||||
|
raise ApiError(400, f"mgr '{mgr_id}' already exists")
|
||||||
|
mgr[mgr_id] = {"name": mgr_id, "state": "active"}
|
||||||
|
await _save_node_ceph(request, node, ceph)
|
||||||
|
return _upid(node, "cephcreatemgr")
|
||||||
|
|
||||||
|
async def mgr_delete(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
mgr_id = str(payload["id"])
|
||||||
|
ceph = await _load_node_ceph(request, node)
|
||||||
|
mgr = ceph.setdefault("mgr", {})
|
||||||
|
if mgr_id not in mgr:
|
||||||
|
raise ApiError(404, "mgr does not exist")
|
||||||
|
del mgr[mgr_id]
|
||||||
|
await _save_node_ceph(request, node, ceph)
|
||||||
|
return _upid(node, "cephdestroymgr")
|
||||||
|
|
||||||
|
async def mon_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
ceph = await _load_node_ceph(request, node)
|
||||||
|
return [
|
||||||
|
{"name": name, **data}
|
||||||
|
for name, data in sorted((ceph.get("mon") or {}).items())
|
||||||
|
if isinstance(data, dict)
|
||||||
|
]
|
||||||
|
|
||||||
|
async def mon_create(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
monid = str(payload["monid"])
|
||||||
|
ceph = await _load_node_ceph(request, node)
|
||||||
|
mons = ceph.setdefault("mon", {})
|
||||||
|
if monid in mons:
|
||||||
|
raise ApiError(400, f"mon '{monid}' already exists")
|
||||||
|
mons[monid] = {
|
||||||
|
"name": monid,
|
||||||
|
"addr": str(payload.get("mon-address") or f"{node}.local:6789"),
|
||||||
|
"rank": len(mons),
|
||||||
|
}
|
||||||
|
await _save_node_ceph(request, node, ceph)
|
||||||
|
return _upid(node, "cephcreatemon")
|
||||||
|
|
||||||
|
async def mon_delete(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
monid = str(payload["monid"])
|
||||||
|
ceph = await _load_node_ceph(request, node)
|
||||||
|
mons = ceph.setdefault("mon", {})
|
||||||
|
if monid not in mons:
|
||||||
|
raise ApiError(404, "mon does not exist")
|
||||||
|
del mons[monid]
|
||||||
|
await _save_node_ceph(request, node, ceph)
|
||||||
|
return _upid(node, "cephdestroymon")
|
||||||
|
|
||||||
|
async def osd_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
rows = await database(request).pool.fetch(
|
||||||
|
"""SELECT r.external_id, r.state
|
||||||
|
FROM resources r JOIN nodes n ON n.id=r.node_id
|
||||||
|
WHERE n.name=$1 AND r.kind='ceph-osd'
|
||||||
|
ORDER BY r.external_id""",
|
||||||
|
node,
|
||||||
|
)
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
payload = state(row["state"])
|
||||||
|
osd_id = payload.get("osd_id", row["external_id"])
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"osd": int(osd_id) if str(osd_id).isdigit() else osd_id,
|
||||||
|
"status": payload.get("status", "up"),
|
||||||
|
"in": 1 if payload.get("in", True) else 0,
|
||||||
|
"weight": payload.get("weight", 1.0),
|
||||||
|
"device_class": payload.get("device_class", "hdd"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def osd_create(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
next_id = await database(request).pool.fetchval(
|
||||||
|
"""SELECT COALESCE(
|
||||||
|
MAX(NULLIF(regexp_replace(external_id, '\\D', '', 'g'), '')::int),
|
||||||
|
-1
|
||||||
|
) + 1
|
||||||
|
FROM resources r JOIN nodes n ON n.id=r.node_id
|
||||||
|
WHERE n.name=$1 AND r.kind='ceph-osd'""",
|
||||||
|
node,
|
||||||
|
)
|
||||||
|
osd_id = int(next_id or 0)
|
||||||
|
external_id = f"osd.{osd_id}"
|
||||||
|
node_id = await database(request).pool.fetchval(
|
||||||
|
"SELECT id FROM nodes WHERE name=$1",
|
||||||
|
node,
|
||||||
|
)
|
||||||
|
osd_state = {
|
||||||
|
"osd_id": osd_id,
|
||||||
|
"status": "up",
|
||||||
|
"in": True,
|
||||||
|
"weight": 1.0,
|
||||||
|
"device_class": payload.get("crush-device-class") or "hdd",
|
||||||
|
"dev": payload.get("dev"),
|
||||||
|
"size_bytes": 0,
|
||||||
|
"used_bytes": 0,
|
||||||
|
}
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"""INSERT INTO resources(id, node_id, kind, external_id, state)
|
||||||
|
VALUES(gen_random_uuid(), $1, 'ceph-osd', $2, $3::jsonb)""",
|
||||||
|
node_id,
|
||||||
|
external_id,
|
||||||
|
json.dumps(osd_state, sort_keys=True),
|
||||||
|
)
|
||||||
|
return _upid(node, "cephcreateosd")
|
||||||
|
|
||||||
|
async def osd_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
osdid = str(values(inputs)["osdid"])
|
||||||
|
await require_node(request, node)
|
||||||
|
row = await _osd_row(request, node, osdid)
|
||||||
|
payload = state(row["state"])
|
||||||
|
return {
|
||||||
|
"osd": int(osdid) if osdid.isdigit() else osdid,
|
||||||
|
"status": payload.get("status", "up"),
|
||||||
|
"in": 1 if payload.get("in", True) else 0,
|
||||||
|
"weight": payload.get("weight", 1.0),
|
||||||
|
"size": payload.get("size_bytes", 0),
|
||||||
|
"used": payload.get("used_bytes", 0),
|
||||||
|
"device_class": payload.get("device_class", "hdd"),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def osd_delete(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
osdid = str(payload["osdid"])
|
||||||
|
await require_node(request, node)
|
||||||
|
row = await _osd_row(request, node, osdid)
|
||||||
|
await database(request).pool.execute("DELETE FROM resources WHERE id=$1", row["id"])
|
||||||
|
return _upid(node, "cephdestroyosd")
|
||||||
|
|
||||||
|
async def osd_in(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
osdid = str(payload["osdid"])
|
||||||
|
await require_node(request, node)
|
||||||
|
row = await _osd_row(request, node, osdid)
|
||||||
|
current = state(row["state"])
|
||||||
|
current["in"] = True
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"UPDATE resources SET state=$2::jsonb, version=version+1 WHERE id=$1",
|
||||||
|
row["id"],
|
||||||
|
json.dumps(current, sort_keys=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def osd_out(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
osdid = str(payload["osdid"])
|
||||||
|
await require_node(request, node)
|
||||||
|
row = await _osd_row(request, node, osdid)
|
||||||
|
current = state(row["state"])
|
||||||
|
current["in"] = False
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"UPDATE resources SET state=$2::jsonb, version=version+1 WHERE id=$1",
|
||||||
|
row["id"],
|
||||||
|
json.dumps(current, sort_keys=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def osd_scrub(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
osdid = str(payload["osdid"])
|
||||||
|
await require_node(request, node)
|
||||||
|
row = await _osd_row(request, node, osdid)
|
||||||
|
current = state(row["state"])
|
||||||
|
current["last_scrub"] = {
|
||||||
|
"deep": int(bool(payload.get("deep"))),
|
||||||
|
"token": secrets.token_hex(4),
|
||||||
|
}
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"UPDATE resources SET state=$2::jsonb, version=version+1 WHERE id=$1",
|
||||||
|
row["id"],
|
||||||
|
json.dumps(current, sort_keys=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def osd_lv_info(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
osdid = str(payload["osdid"])
|
||||||
|
await require_node(request, node)
|
||||||
|
row = await _osd_row(request, node, osdid)
|
||||||
|
current = state(row["state"])
|
||||||
|
return {
|
||||||
|
"lv_name": f"osd-block-{osdid}",
|
||||||
|
"lv_path": f"/dev/ceph/{osdid}",
|
||||||
|
"lv_size": current.get("size_bytes", 0),
|
||||||
|
"type": payload.get("type") or "block",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def osd_metadata(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
osdid = str(payload["osdid"])
|
||||||
|
await require_node(request, node)
|
||||||
|
row = await _osd_row(request, node, osdid)
|
||||||
|
current = state(row["state"])
|
||||||
|
return {
|
||||||
|
"osd": {
|
||||||
|
"id": int(osdid) if osdid.isdigit() else osdid,
|
||||||
|
"uuid": current.get("uuid") or f"osd-uuid-{osdid}",
|
||||||
|
"device_class": current.get("device_class", "hdd"),
|
||||||
|
},
|
||||||
|
"devices": [{"dev": current.get("dev") or f"/dev/sd{osdid}"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def cluster_ceph_status(_request: Request, _inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
row = await database(_request).pool.fetchrow(
|
||||||
|
"""SELECT capacity_bytes, used_bytes FROM storages
|
||||||
|
WHERE storage_type='ceph' ORDER BY storage_id LIMIT 1"""
|
||||||
|
)
|
||||||
|
total = int(row["capacity_bytes"] or 0) if row is not None else 0
|
||||||
|
used = int(row["used_bytes"] or 0) if row is not None else 0
|
||||||
|
osd_count = await database(_request).pool.fetchval(
|
||||||
|
"SELECT count(*)::int FROM resources WHERE kind='ceph-osd'"
|
||||||
|
)
|
||||||
|
ceph = await _load_cluster_ceph(_request)
|
||||||
|
return {
|
||||||
|
"version": "17.2.7",
|
||||||
|
"health": {"status": "HEALTH_OK" if ceph.get("running", True) else "HEALTH_WARN"},
|
||||||
|
"osdmap": {
|
||||||
|
"num_osds": osd_count,
|
||||||
|
"num_up_osds": osd_count - 1,
|
||||||
|
"num_in_osds": osd_count - 1,
|
||||||
|
},
|
||||||
|
"pgmap": {"bytes_used": used, "bytes_total": total},
|
||||||
|
"fsmap": {"filesystems": list((ceph.get("fs") or {}).keys())},
|
||||||
|
}
|
||||||
|
|
||||||
|
base = "/nodes/{node}/ceph"
|
||||||
|
registry.register(base, "GET", ceph_index)
|
||||||
|
registry.register(f"{base}/cfg", "GET", cfg_index)
|
||||||
|
registry.register(f"{base}/cfg/db", "GET", cfg_db)
|
||||||
|
registry.register(f"{base}/cfg/raw", "GET", cfg_raw)
|
||||||
|
registry.register(f"{base}/cfg/value", "GET", cfg_value)
|
||||||
|
registry.register(f"{base}/cmd-safety", "GET", cmd_safety)
|
||||||
|
registry.register(f"{base}/crush", "GET", crush)
|
||||||
|
registry.register(f"{base}/fs", "GET", fs_list)
|
||||||
|
registry.register(f"{base}/fs/{{name}}", "POST", fs_create)
|
||||||
|
registry.register(f"{base}/fs/{{name}}", "DELETE", fs_delete)
|
||||||
|
registry.register(f"{base}/init", "POST", init)
|
||||||
|
registry.register(f"{base}/log", "GET", log)
|
||||||
|
registry.register(f"{base}/mds", "GET", mds_list)
|
||||||
|
registry.register(f"{base}/mds/{{name}}", "POST", mds_create)
|
||||||
|
registry.register(f"{base}/mds/{{name}}", "DELETE", mds_delete)
|
||||||
|
registry.register(f"{base}/mgr", "GET", mgr_list)
|
||||||
|
registry.register(f"{base}/mgr/{{id}}", "POST", mgr_create)
|
||||||
|
registry.register(f"{base}/mgr/{{id}}", "DELETE", mgr_delete)
|
||||||
|
registry.register(f"{base}/mon", "GET", mon_list)
|
||||||
|
registry.register(f"{base}/mon/{{monid}}", "POST", mon_create)
|
||||||
|
registry.register(f"{base}/mon/{{monid}}", "DELETE", mon_delete)
|
||||||
|
registry.register(f"{base}/osd", "GET", osd_list)
|
||||||
|
registry.register(f"{base}/osd", "POST", osd_create)
|
||||||
|
registry.register(f"{base}/osd/{{osdid}}", "GET", osd_get)
|
||||||
|
registry.register(f"{base}/osd/{{osdid}}", "DELETE", osd_delete)
|
||||||
|
registry.register(f"{base}/osd/{{osdid}}/in", "POST", osd_in)
|
||||||
|
registry.register(f"{base}/osd/{{osdid}}/out", "POST", osd_out)
|
||||||
|
registry.register(f"{base}/osd/{{osdid}}/scrub", "POST", osd_scrub)
|
||||||
|
registry.register(f"{base}/osd/{{osdid}}/lv-info", "GET", osd_lv_info)
|
||||||
|
registry.register(f"{base}/osd/{{osdid}}/metadata", "GET", osd_metadata)
|
||||||
|
registry.register(f"{base}/pool", "GET", pool_list)
|
||||||
|
registry.register(f"{base}/pool", "POST", pool_create)
|
||||||
|
registry.register(f"{base}/pool/{{name}}", "GET", pool_get)
|
||||||
|
registry.register(f"{base}/pool/{{name}}", "PUT", pool_update)
|
||||||
|
registry.register(f"{base}/pool/{{name}}", "DELETE", pool_delete)
|
||||||
|
registry.register(f"{base}/pool/{{name}}/status", "GET", pool_status)
|
||||||
|
registry.register(f"{base}/rules", "GET", rules)
|
||||||
|
registry.register(f"{base}/status", "GET", status)
|
||||||
|
registry.register(f"{base}/start", "POST", start)
|
||||||
|
registry.register(f"{base}/stop", "POST", stop)
|
||||||
|
registry.register(f"{base}/restart", "POST", restart)
|
||||||
|
registry.register("/cluster/ceph/status", "GET", cluster_ceph_status)
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
"""Cluster-level semantic handlers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from app.api.errors import ApiError
|
||||||
|
from app.api.registry import HandlerRegistry
|
||||||
|
from app.handlers.common import (
|
||||||
|
cluster_metadata,
|
||||||
|
database,
|
||||||
|
save_cluster_metadata,
|
||||||
|
state,
|
||||||
|
subdirs,
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
from app.simulation.seed import CLUSTER_ID
|
||||||
|
|
||||||
|
|
||||||
|
def _replication_jobs(metadata: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
jobs = metadata.get("replication", [])
|
||||||
|
if not isinstance(jobs, list):
|
||||||
|
return []
|
||||||
|
return [dict(item) for item in jobs if isinstance(item, dict)]
|
||||||
|
|
||||||
|
|
||||||
|
def register_cluster_handlers(registry: HandlerRegistry) -> None:
|
||||||
|
async def cluster_index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
return subdirs(
|
||||||
|
"acme",
|
||||||
|
"backup",
|
||||||
|
"backup-info",
|
||||||
|
"config",
|
||||||
|
"ha",
|
||||||
|
"log",
|
||||||
|
"mapping",
|
||||||
|
"nextid",
|
||||||
|
"notifications",
|
||||||
|
"options",
|
||||||
|
"replication",
|
||||||
|
"sdn",
|
||||||
|
"status",
|
||||||
|
"tasks",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def cluster_status(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
rows = await database(_request).pool.fetch(
|
||||||
|
"""SELECT id, name, status FROM nodes ORDER BY name"""
|
||||||
|
)
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
for index, row in enumerate(rows):
|
||||||
|
online = str(row["status"]) == "online"
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"id": str(row["id"]),
|
||||||
|
"name": str(row["name"]),
|
||||||
|
"nodeid": index,
|
||||||
|
"online": 1 if online else 0,
|
||||||
|
"local": 1 if index == 0 else 0,
|
||||||
|
"ip": f"10.32.{index // 254 + 1}.{index % 254 + 10}",
|
||||||
|
"level": "c",
|
||||||
|
"type": "node",
|
||||||
|
"quorate": 1,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def cluster_nextid(request: Request, inputs: dict[str, Any]) -> int:
|
||||||
|
requested = values(inputs).get("vmid")
|
||||||
|
if requested is not None:
|
||||||
|
candidate = int(requested)
|
||||||
|
taken = await database(request).pool.fetchval(
|
||||||
|
"""SELECT EXISTS(
|
||||||
|
SELECT 1 FROM resources WHERE kind IN ('qemu', 'lxc') AND external_id=$1
|
||||||
|
)""",
|
||||||
|
str(candidate),
|
||||||
|
)
|
||||||
|
if not taken:
|
||||||
|
return candidate
|
||||||
|
raise ApiError(400, f"VMID {candidate} already exists")
|
||||||
|
maximum = await database(request).pool.fetchval(
|
||||||
|
"""SELECT COALESCE(MAX(external_id::integer), 99)
|
||||||
|
FROM resources WHERE kind IN ('qemu', 'lxc')"""
|
||||||
|
)
|
||||||
|
return int(maximum) + 1
|
||||||
|
|
||||||
|
async def cluster_options_get(_request: Request, _inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
row = await database(_request).pool.fetchrow(
|
||||||
|
"SELECT metadata FROM clusters WHERE id=$1",
|
||||||
|
CLUSTER_ID,
|
||||||
|
)
|
||||||
|
metadata = state(row["metadata"]) if row is not None else {}
|
||||||
|
options = metadata.get("options", {})
|
||||||
|
if not isinstance(options, dict):
|
||||||
|
options = {}
|
||||||
|
return {
|
||||||
|
"keyboard": options.get("keyboard", "en-us"),
|
||||||
|
"email_from": options.get("email_from", "root@localhost"),
|
||||||
|
"http_proxy": options.get("http_proxy", ""),
|
||||||
|
"description": options.get("description", "Proxmox API emulator cluster"),
|
||||||
|
**options,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def cluster_options_put(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
current = await cluster_options_get(request, inputs)
|
||||||
|
provided = values(inputs)
|
||||||
|
updated = {**current, **{key: value for key, value in provided.items() if key != "node"}}
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"""UPDATE clusters SET metadata = jsonb_set(
|
||||||
|
COALESCE(metadata, '{}'::jsonb), '{options}', $2::jsonb, true
|
||||||
|
), updated_at=now() WHERE id=$1""",
|
||||||
|
CLUSTER_ID,
|
||||||
|
json.dumps(updated, sort_keys=True),
|
||||||
|
)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
async def cluster_log(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
limit = int(values(inputs).get("max") or 50)
|
||||||
|
rows = await database(request).pool.fetch(
|
||||||
|
"""SELECT tl.message, tl.sequence
|
||||||
|
FROM task_logs tl
|
||||||
|
ORDER BY tl.created_at DESC, tl.sequence DESC
|
||||||
|
LIMIT $1""",
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
return [{"n": int(row["sequence"]), "t": str(row["message"])} for row in reversed(rows)]
|
||||||
|
|
||||||
|
async def cluster_tasks(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
rows = await database(_request).pool.fetch(
|
||||||
|
"SELECT upid FROM tasks ORDER BY created_at DESC LIMIT 1000"
|
||||||
|
)
|
||||||
|
return [{"upid": str(row["upid"])} for row in rows]
|
||||||
|
|
||||||
|
async def replication_list(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
return _replication_jobs(metadata)
|
||||||
|
|
||||||
|
async def replication_create(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
from app.handlers.common import require_value
|
||||||
|
|
||||||
|
payload = values(inputs)
|
||||||
|
guest = str(require_value(payload, "guest"))
|
||||||
|
target = str(require_value(payload, "target"))
|
||||||
|
job_id = str(payload.get("id") or f"repl-{guest.replace(':', '-')}")
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
jobs = _replication_jobs(metadata)
|
||||||
|
if any(str(item.get("id")) == job_id for item in jobs):
|
||||||
|
raise ApiError(409, "replication job already exists")
|
||||||
|
job = {
|
||||||
|
"id": job_id,
|
||||||
|
"guest": guest,
|
||||||
|
"target": target,
|
||||||
|
"type": str(payload.get("type") or "local"),
|
||||||
|
"schedule": str(payload.get("schedule") or "*/15"),
|
||||||
|
"rate": int(payload.get("rate") or 1),
|
||||||
|
"comment": str(payload.get("comment") or ""),
|
||||||
|
"enabled": int(payload.get("enabled", 1)),
|
||||||
|
}
|
||||||
|
jobs.append(job)
|
||||||
|
metadata["replication"] = jobs
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
return job
|
||||||
|
|
||||||
|
async def replication_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
job_id = str(values(inputs)["id"])
|
||||||
|
for job in _replication_jobs(await cluster_metadata(request)):
|
||||||
|
if str(job.get("id")) == job_id:
|
||||||
|
return job
|
||||||
|
raise ApiError(404, "replication job does not exist")
|
||||||
|
|
||||||
|
async def replication_update(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
job_id = str(values(inputs)["id"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
jobs = _replication_jobs(metadata)
|
||||||
|
for index, job in enumerate(jobs):
|
||||||
|
if str(job.get("id")) != job_id:
|
||||||
|
continue
|
||||||
|
payload = values(inputs)
|
||||||
|
updated = {
|
||||||
|
**job,
|
||||||
|
**{
|
||||||
|
key: value
|
||||||
|
for key, value in payload.items()
|
||||||
|
if key not in {"id", "delete", "digest"}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
jobs[index] = updated
|
||||||
|
metadata["replication"] = jobs
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
return updated
|
||||||
|
raise ApiError(404, "replication job does not exist")
|
||||||
|
|
||||||
|
async def replication_delete(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
job_id = str(values(inputs)["id"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
jobs = _replication_jobs(metadata)
|
||||||
|
remaining = [job for job in jobs if str(job.get("id")) != job_id]
|
||||||
|
if len(remaining) == len(jobs):
|
||||||
|
raise ApiError(404, "replication job does not exist")
|
||||||
|
metadata["replication"] = remaining
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
|
||||||
|
registry.register("/cluster", "GET", cluster_index)
|
||||||
|
registry.register("/cluster/status", "GET", cluster_status)
|
||||||
|
registry.register("/cluster/nextid", "GET", cluster_nextid)
|
||||||
|
registry.register("/cluster/options", "GET", cluster_options_get)
|
||||||
|
registry.register("/cluster/options", "PUT", cluster_options_put)
|
||||||
|
registry.register("/cluster/log", "GET", cluster_log)
|
||||||
|
registry.register("/cluster/tasks", "GET", cluster_tasks)
|
||||||
|
registry.register("/cluster/replication", "GET", replication_list)
|
||||||
|
registry.register("/cluster/replication", "POST", replication_create)
|
||||||
|
registry.register("/cluster/replication/{id}", "GET", replication_get)
|
||||||
|
registry.register("/cluster/replication/{id}", "PUT", replication_update)
|
||||||
|
registry.register("/cluster/replication/{id}", "DELETE", replication_delete)
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"""Cluster config / join / totem handlers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from app.api.errors import ApiError
|
||||||
|
from app.api.registry import HandlerRegistry
|
||||||
|
from app.handlers.common import (
|
||||||
|
cluster_metadata,
|
||||||
|
database,
|
||||||
|
save_cluster_metadata,
|
||||||
|
subdirs,
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _config(metadata: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
current = metadata.setdefault(
|
||||||
|
"cluster_config",
|
||||||
|
{
|
||||||
|
"clustername": "pve-simulator",
|
||||||
|
"votes": 1,
|
||||||
|
"links": {},
|
||||||
|
"join_info": {},
|
||||||
|
"totem": {"version": 2, "secauth": "on", "cluster_name": "pve-simulator"},
|
||||||
|
"qdevice": {"status": "disabled"},
|
||||||
|
"apiversion": 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not isinstance(current, dict):
|
||||||
|
current = {
|
||||||
|
"clustername": "pve-simulator",
|
||||||
|
"votes": 1,
|
||||||
|
"links": {},
|
||||||
|
"join_info": {},
|
||||||
|
"totem": {"version": 2, "secauth": "on", "cluster_name": "pve-simulator"},
|
||||||
|
"qdevice": {"status": "disabled"},
|
||||||
|
"apiversion": 1,
|
||||||
|
}
|
||||||
|
metadata["cluster_config"] = current
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
def register_cluster_config_handlers(registry: HandlerRegistry) -> None:
|
||||||
|
async def index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
return subdirs("apiversion", "join", "nodes", "qdevice", "totem")
|
||||||
|
|
||||||
|
async def create(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
config = _config(metadata)
|
||||||
|
if payload.get("clustername"):
|
||||||
|
config["clustername"] = str(payload["clustername"])
|
||||||
|
config.setdefault("totem", {})["cluster_name"] = str(payload["clustername"])
|
||||||
|
if "votes" in payload:
|
||||||
|
config["votes"] = payload["votes"]
|
||||||
|
if "nodeid" in payload:
|
||||||
|
config["creator_nodeid"] = payload["nodeid"]
|
||||||
|
links = {key: value for key, value in payload.items() if key.startswith("link")}
|
||||||
|
if links:
|
||||||
|
config["links"] = links
|
||||||
|
config["token"] = secrets.token_hex(16)
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"""UPDATE clusters
|
||||||
|
SET name=$1, updated_at=now()
|
||||||
|
WHERE id=(SELECT id FROM clusters LIMIT 1)""",
|
||||||
|
str(config["clustername"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def apiversion(_request: Request, _inputs: dict[str, Any]) -> int:
|
||||||
|
metadata = await cluster_metadata(_request)
|
||||||
|
return int(_config(metadata).get("apiversion") or 1)
|
||||||
|
|
||||||
|
async def join_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
config = _config(metadata)
|
||||||
|
node = values(inputs).get("node")
|
||||||
|
rows = await database(request).pool.fetch("SELECT name, status FROM nodes ORDER BY name")
|
||||||
|
nodelist = [
|
||||||
|
{"name": str(row["name"]), "online": 1 if row["status"] == "online" else 0}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"clustername": config.get("clustername"),
|
||||||
|
"config_digest": secrets.token_hex(8),
|
||||||
|
"nodelist": nodelist,
|
||||||
|
"preferred_node": node or (nodelist[0]["name"] if nodelist else None),
|
||||||
|
"totem": config.get("totem", {}),
|
||||||
|
"links": config.get("links", {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def join_post(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
config = _config(metadata)
|
||||||
|
hostname = str(payload.get("hostname") or payload.get("node") or "")
|
||||||
|
if not hostname:
|
||||||
|
raise ApiError(400, "parameter verification failed - 'hostname' missing")
|
||||||
|
joins = config.setdefault("join_info", {})
|
||||||
|
joins[hostname] = {
|
||||||
|
"hostname": hostname,
|
||||||
|
"fingerprint": payload.get("fingerprint"),
|
||||||
|
"nodeid": payload.get("nodeid"),
|
||||||
|
"votes": payload.get("votes", 1),
|
||||||
|
"force": payload.get("force"),
|
||||||
|
}
|
||||||
|
# password accepted but not stored in clear form
|
||||||
|
if payload.get("password"):
|
||||||
|
joins[hostname]["password_set"] = True
|
||||||
|
exists = await database(request).pool.fetchval(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)",
|
||||||
|
hostname,
|
||||||
|
)
|
||||||
|
if not exists:
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"""INSERT INTO nodes(id, name, status, metadata)
|
||||||
|
VALUES(gen_random_uuid(), $1, 'online', '{}'::jsonb)""",
|
||||||
|
hostname,
|
||||||
|
)
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
|
||||||
|
async def nodes_list(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
rows = await database(request).pool.fetch("SELECT name, status FROM nodes ORDER BY name")
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
config = _config(metadata)
|
||||||
|
result = []
|
||||||
|
for index, row in enumerate(rows, start=1):
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"node": str(row["name"]),
|
||||||
|
"nodeid": index,
|
||||||
|
"ring0_addr": f"{row['name']}.local",
|
||||||
|
"quorum_votes": config.get("votes", 1),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def nodes_add(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
config = _config(metadata)
|
||||||
|
added = config.setdefault("added_nodes", {})
|
||||||
|
added[node] = {
|
||||||
|
"node": node,
|
||||||
|
"nodeid": payload.get("nodeid"),
|
||||||
|
"new_node_ip": payload.get("new_node_ip"),
|
||||||
|
"votes": payload.get("votes", 1),
|
||||||
|
"apiversion": payload.get("apiversion"),
|
||||||
|
"force": payload.get("force"),
|
||||||
|
}
|
||||||
|
links = {key: value for key, value in payload.items() if key.startswith("link")}
|
||||||
|
if links:
|
||||||
|
added[node]["links"] = links
|
||||||
|
exists = await database(request).pool.fetchval(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)",
|
||||||
|
node,
|
||||||
|
)
|
||||||
|
if not exists:
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"""INSERT INTO nodes(id, name, status, metadata)
|
||||||
|
VALUES(gen_random_uuid(), $1, 'online', '{}'::jsonb)""",
|
||||||
|
node,
|
||||||
|
)
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
|
||||||
|
async def nodes_delete(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
config = _config(metadata)
|
||||||
|
added = config.setdefault("added_nodes", {})
|
||||||
|
added.pop(node, None)
|
||||||
|
joins = config.setdefault("join_info", {})
|
||||||
|
joins.pop(node, None)
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
# Keep node row; mark offline to avoid cascading guest deletes.
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"UPDATE nodes SET status='offline', updated_at=now() WHERE name=$1",
|
||||||
|
node,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def qdevice(request: Request, _inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
return dict(_config(metadata).get("qdevice") or {"status": "disabled"})
|
||||||
|
|
||||||
|
async def totem(request: Request, _inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
return dict(_config(metadata).get("totem") or {})
|
||||||
|
|
||||||
|
registry.register("/cluster/config", "GET", index)
|
||||||
|
registry.register("/cluster/config", "POST", create)
|
||||||
|
registry.register("/cluster/config/apiversion", "GET", apiversion)
|
||||||
|
registry.register("/cluster/config/join", "GET", join_get)
|
||||||
|
registry.register("/cluster/config/join", "POST", join_post)
|
||||||
|
registry.register("/cluster/config/nodes", "GET", nodes_list)
|
||||||
|
registry.register("/cluster/config/nodes/{node}", "POST", nodes_add)
|
||||||
|
registry.register("/cluster/config/nodes/{node}", "DELETE", nodes_delete)
|
||||||
|
registry.register("/cluster/config/qdevice", "GET", qdevice)
|
||||||
|
registry.register("/cluster/config/totem", "GET", totem)
|
||||||
@@ -0,0 +1,688 @@
|
|||||||
|
"""Additional cluster-level handlers with durable metadata persistence."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from app.api.errors import ApiError
|
||||||
|
from app.api.registry import HandlerRegistry
|
||||||
|
from app.handlers.common import (
|
||||||
|
cluster_metadata,
|
||||||
|
database,
|
||||||
|
require_node,
|
||||||
|
save_cluster_metadata,
|
||||||
|
subdirs,
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
from app.tasks.repository import TaskRepository
|
||||||
|
from app.tasks.upid import Upid
|
||||||
|
|
||||||
|
DEFAULT_CEPH_FLAGS: dict[str, int] = {
|
||||||
|
"nobackfill": 0,
|
||||||
|
"nodeep-scrub": 0,
|
||||||
|
"nodown": 0,
|
||||||
|
"noin": 0,
|
||||||
|
"noout": 0,
|
||||||
|
"norebalance": 0,
|
||||||
|
"norecover": 0,
|
||||||
|
"noscrub": 0,
|
||||||
|
"notieragent": 0,
|
||||||
|
"pause": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_CPU_FLAGS: list[dict[str, Any]] = [
|
||||||
|
{"name": "aes", "introduces": "Westmere"},
|
||||||
|
{"name": "avx", "introduces": "SandyBridge"},
|
||||||
|
{"name": "avx2", "introduces": "Haswell"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _jobs(metadata: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
jobs = metadata.setdefault("jobs", {})
|
||||||
|
if not isinstance(jobs, dict):
|
||||||
|
jobs = {}
|
||||||
|
metadata["jobs"] = jobs
|
||||||
|
sync = jobs.setdefault("realm_sync", {})
|
||||||
|
if not isinstance(sync, dict):
|
||||||
|
sync = {}
|
||||||
|
jobs["realm_sync"] = sync
|
||||||
|
return jobs
|
||||||
|
|
||||||
|
|
||||||
|
def _metrics(metadata: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
metrics = metadata.setdefault("metrics", {})
|
||||||
|
if not isinstance(metrics, dict):
|
||||||
|
metrics = {}
|
||||||
|
metadata["metrics"] = metrics
|
||||||
|
servers = metrics.setdefault("servers", {})
|
||||||
|
if not isinstance(servers, dict):
|
||||||
|
servers = {}
|
||||||
|
metrics["servers"] = servers
|
||||||
|
return metrics
|
||||||
|
|
||||||
|
|
||||||
|
def _cpu_models(metadata: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
models = metadata.get("qemu_cpu_models")
|
||||||
|
if not isinstance(models, dict):
|
||||||
|
models = {}
|
||||||
|
metadata["qemu_cpu_models"] = models
|
||||||
|
return models
|
||||||
|
|
||||||
|
|
||||||
|
def _ha_rules_store(metadata: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
rules = metadata.get("ha_rules")
|
||||||
|
if isinstance(rules, dict):
|
||||||
|
return [
|
||||||
|
{"rule": str(name), **dict(value)}
|
||||||
|
for name, value in rules.items()
|
||||||
|
if isinstance(value, dict)
|
||||||
|
]
|
||||||
|
if isinstance(rules, list):
|
||||||
|
return [dict(item) for item in rules if isinstance(item, dict)]
|
||||||
|
defaults = [
|
||||||
|
{"rule": "node-fencing", "type": "node", "action": "restart"},
|
||||||
|
{"rule": "service-ha", "type": "resource", "action": "failover"},
|
||||||
|
]
|
||||||
|
metadata["ha_rules"] = defaults
|
||||||
|
return list(defaults)
|
||||||
|
|
||||||
|
|
||||||
|
def _save_ha_rules(metadata: dict[str, Any], rules: list[dict[str, Any]]) -> None:
|
||||||
|
metadata["ha_rules"] = rules
|
||||||
|
|
||||||
|
|
||||||
|
def _replication_jobs(metadata: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
jobs = metadata.get("replication", [])
|
||||||
|
if not isinstance(jobs, list):
|
||||||
|
return []
|
||||||
|
return [dict(item) for item in jobs if isinstance(item, dict)]
|
||||||
|
|
||||||
|
|
||||||
|
def _ceph(metadata: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
ceph = metadata.get("ceph")
|
||||||
|
if not isinstance(ceph, dict):
|
||||||
|
ceph = {}
|
||||||
|
flags = ceph.get("flags")
|
||||||
|
if not isinstance(flags, dict):
|
||||||
|
flags = copy.deepcopy(DEFAULT_CEPH_FLAGS)
|
||||||
|
else:
|
||||||
|
merged = copy.deepcopy(DEFAULT_CEPH_FLAGS)
|
||||||
|
merged.update({str(key): int(value) for key, value in flags.items()})
|
||||||
|
flags = merged
|
||||||
|
ceph["flags"] = flags
|
||||||
|
metadata["ceph"] = ceph
|
||||||
|
return ceph
|
||||||
|
|
||||||
|
|
||||||
|
async def _cluster_task(request: Request, *, task_type: str, worker: str) -> str:
|
||||||
|
from app.db.primitives import ConflictError
|
||||||
|
|
||||||
|
pool = database(request).pool
|
||||||
|
node = await pool.fetchval("SELECT name FROM nodes ORDER BY name LIMIT 1") or "localhost"
|
||||||
|
upid = str(Upid.allocate(str(node), worker, "0", str(request.state.principal)))
|
||||||
|
try:
|
||||||
|
task = await TaskRepository(pool).create(
|
||||||
|
upid=upid,
|
||||||
|
task_type=task_type,
|
||||||
|
payload={"cluster": True},
|
||||||
|
resource_key=f"cluster:{task_type}:{secrets.token_hex(4)}",
|
||||||
|
)
|
||||||
|
except ConflictError as error:
|
||||||
|
raise ApiError(409, str(error)) from error
|
||||||
|
return task.upid
|
||||||
|
|
||||||
|
|
||||||
|
async def _bulk_guest_status(request: Request, status: str) -> None:
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"""UPDATE resources
|
||||||
|
SET state = jsonb_set(COALESCE(state, '{}'::jsonb), '{status}', to_jsonb($1::text), true),
|
||||||
|
updated_at=now()
|
||||||
|
WHERE kind IN ('qemu', 'lxc')""",
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register_cluster_extra_handlers(registry: HandlerRegistry) -> None:
|
||||||
|
async def jobs_index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
return subdirs("realm-sync", "schedule-analyze")
|
||||||
|
|
||||||
|
async def realm_sync_list(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
jobs = _jobs(metadata)
|
||||||
|
items = [
|
||||||
|
{"id": job_id, **dict(payload)}
|
||||||
|
for job_id, payload in sorted(jobs.get("realm_sync", {}).items())
|
||||||
|
if isinstance(payload, dict)
|
||||||
|
]
|
||||||
|
return items
|
||||||
|
|
||||||
|
async def realm_sync_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
job_id = str(values(inputs)["id"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
jobs = _jobs(metadata)
|
||||||
|
payload = jobs.get("realm_sync", {}).get(job_id)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ApiError(404, "realm-sync job does not exist")
|
||||||
|
return {"id": job_id, **payload}
|
||||||
|
|
||||||
|
async def realm_sync_create(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = values(inputs)
|
||||||
|
job_id = str(payload["id"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
jobs = _jobs(metadata)
|
||||||
|
sync = jobs.setdefault("realm_sync", {})
|
||||||
|
if job_id in sync:
|
||||||
|
raise ApiError(409, "realm-sync job already exists")
|
||||||
|
entry = {
|
||||||
|
key: value for key, value in payload.items() if key not in {"id", "delete", "digest"}
|
||||||
|
}
|
||||||
|
entry.setdefault("schedule", "0 0 * * *")
|
||||||
|
entry.setdefault("enabled", 1)
|
||||||
|
entry.setdefault("realm", str(payload.get("realm") or "pam"))
|
||||||
|
sync[job_id] = entry
|
||||||
|
metadata["jobs"] = jobs
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
return {"id": job_id, **entry}
|
||||||
|
|
||||||
|
async def realm_sync_update(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = values(inputs)
|
||||||
|
job_id = str(payload["id"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
jobs = _jobs(metadata)
|
||||||
|
sync = jobs.setdefault("realm_sync", {})
|
||||||
|
if job_id not in sync:
|
||||||
|
raise ApiError(404, "realm-sync job does not exist")
|
||||||
|
updated = {
|
||||||
|
**sync[job_id],
|
||||||
|
**{
|
||||||
|
key: value
|
||||||
|
for key, value in payload.items()
|
||||||
|
if key not in {"id", "delete", "digest"}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
sync[job_id] = updated
|
||||||
|
metadata["jobs"] = jobs
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
return {"id": job_id, **updated}
|
||||||
|
|
||||||
|
async def realm_sync_delete(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
job_id = str(values(inputs)["id"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
jobs = _jobs(metadata)
|
||||||
|
sync = jobs.setdefault("realm_sync", {})
|
||||||
|
if job_id not in sync:
|
||||||
|
raise ApiError(404, "realm-sync job does not exist")
|
||||||
|
del sync[job_id]
|
||||||
|
metadata["jobs"] = jobs
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
|
||||||
|
async def schedule_analyze(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
schedule = str(values(inputs).get("schedule") or "*/15")
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
jobs = _jobs(metadata)
|
||||||
|
jobs["last_schedule_analyze"] = {"schedule": schedule, "at": int(time.time())}
|
||||||
|
metadata["jobs"] = jobs
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
now = int(time.time())
|
||||||
|
return [{"timestamp": now + offset * 900, "utc": True} for offset in range(4)]
|
||||||
|
|
||||||
|
async def metrics_index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
return subdirs("export", "server")
|
||||||
|
|
||||||
|
async def metrics_export(request: Request, _inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
metrics = _metrics(metadata)
|
||||||
|
return {
|
||||||
|
"data": metrics.get("export_data")
|
||||||
|
or '# HELP pve_up Node is up\npve_up{node="pve01"} 1\n',
|
||||||
|
"timestamp": int(time.time()),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def metrics_server_list(
|
||||||
|
request: Request, _inputs: dict[str, Any]
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
metrics = _metrics(metadata)
|
||||||
|
return [
|
||||||
|
{"id": server_id, **dict(payload)}
|
||||||
|
for server_id, payload in sorted(metrics.get("servers", {}).items())
|
||||||
|
if isinstance(payload, dict)
|
||||||
|
]
|
||||||
|
|
||||||
|
async def metrics_server_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
server_id = str(values(inputs)["id"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
metrics = _metrics(metadata)
|
||||||
|
payload = metrics.get("servers", {}).get(server_id)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ApiError(404, "metrics server does not exist")
|
||||||
|
return {"id": server_id, **payload}
|
||||||
|
|
||||||
|
async def metrics_server_create(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = values(inputs)
|
||||||
|
server_id = str(payload["id"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
metrics = _metrics(metadata)
|
||||||
|
servers = metrics.setdefault("servers", {})
|
||||||
|
if server_id in servers:
|
||||||
|
raise ApiError(409, "metrics server already exists")
|
||||||
|
entry = {
|
||||||
|
key: value for key, value in payload.items() if key not in {"id", "delete", "digest"}
|
||||||
|
}
|
||||||
|
entry.setdefault("type", "influxdb")
|
||||||
|
entry.setdefault("server", "127.0.0.1")
|
||||||
|
entry.setdefault("port", 8086)
|
||||||
|
entry.setdefault("enable", 1)
|
||||||
|
servers[server_id] = entry
|
||||||
|
metadata["metrics"] = metrics
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
return {"id": server_id, **entry}
|
||||||
|
|
||||||
|
async def metrics_server_update(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = values(inputs)
|
||||||
|
server_id = str(payload["id"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
metrics = _metrics(metadata)
|
||||||
|
servers = metrics.setdefault("servers", {})
|
||||||
|
if server_id not in servers:
|
||||||
|
raise ApiError(404, "metrics server does not exist")
|
||||||
|
updated = {
|
||||||
|
**servers[server_id],
|
||||||
|
**{
|
||||||
|
key: value
|
||||||
|
for key, value in payload.items()
|
||||||
|
if key not in {"id", "delete", "digest"}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
servers[server_id] = updated
|
||||||
|
metadata["metrics"] = metrics
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
return {"id": server_id, **updated}
|
||||||
|
|
||||||
|
async def metrics_server_delete(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
server_id = str(values(inputs)["id"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
metrics = _metrics(metadata)
|
||||||
|
servers = metrics.setdefault("servers", {})
|
||||||
|
if server_id not in servers:
|
||||||
|
raise ApiError(404, "metrics server does not exist")
|
||||||
|
del servers[server_id]
|
||||||
|
metadata["metrics"] = metrics
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
|
||||||
|
async def qemu_index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
return subdirs("cpu-flags", "custom-cpu-models")
|
||||||
|
|
||||||
|
async def qemu_cpu_flags(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
return list(DEFAULT_CPU_FLAGS)
|
||||||
|
|
||||||
|
async def cpu_models_list(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
models = _cpu_models(metadata)
|
||||||
|
return [
|
||||||
|
{"name": name, **dict(payload)}
|
||||||
|
for name, payload in sorted(models.items())
|
||||||
|
if isinstance(payload, dict)
|
||||||
|
]
|
||||||
|
|
||||||
|
async def cpu_models_create(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = values(inputs)
|
||||||
|
name = str(payload.get("name") or payload.get("cputype") or "")
|
||||||
|
if not name:
|
||||||
|
raise ApiError(400, "parameter verification failed - 'name' missing")
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
models = _cpu_models(metadata)
|
||||||
|
if name in models:
|
||||||
|
raise ApiError(409, "custom cpu model already exists")
|
||||||
|
entry = {
|
||||||
|
key: value
|
||||||
|
for key, value in payload.items()
|
||||||
|
if key not in {"name", "cputype", "delete", "digest"}
|
||||||
|
}
|
||||||
|
entry.setdefault("vendor", "Custom")
|
||||||
|
models[name] = entry
|
||||||
|
metadata["qemu_cpu_models"] = models
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
return {"name": name, **entry}
|
||||||
|
|
||||||
|
async def cpu_models_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
name = str(values(inputs)["cputype"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
models = _cpu_models(metadata)
|
||||||
|
payload = models.get(name)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ApiError(404, "custom cpu model does not exist")
|
||||||
|
return {"name": name, **payload}
|
||||||
|
|
||||||
|
async def cpu_models_update(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = values(inputs)
|
||||||
|
name = str(payload["cputype"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
models = _cpu_models(metadata)
|
||||||
|
if name not in models:
|
||||||
|
raise ApiError(404, "custom cpu model does not exist")
|
||||||
|
updated = {
|
||||||
|
**models[name],
|
||||||
|
**{
|
||||||
|
key: value
|
||||||
|
for key, value in payload.items()
|
||||||
|
if key not in {"cputype", "delete", "digest"}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
models[name] = updated
|
||||||
|
metadata["qemu_cpu_models"] = models
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
return {"name": name, **updated}
|
||||||
|
|
||||||
|
async def cpu_models_delete(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
name = str(values(inputs)["cputype"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
models = _cpu_models(metadata)
|
||||||
|
if name not in models:
|
||||||
|
raise ApiError(404, "custom cpu model does not exist")
|
||||||
|
del models[name]
|
||||||
|
metadata["qemu_cpu_models"] = models
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
|
||||||
|
async def bulk_action_index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
return subdirs("guest")
|
||||||
|
|
||||||
|
async def bulk_guest_index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
return subdirs("migrate", "shutdown", "start", "suspend")
|
||||||
|
|
||||||
|
async def bulk_guest_action(request: Request, inputs: dict[str, Any], action: str) -> str:
|
||||||
|
payload = values(inputs)
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
metadata["last_bulk_action"] = {
|
||||||
|
"action": action,
|
||||||
|
"payload": {
|
||||||
|
key: value for key, value in payload.items() if key not in {"delete", "digest"}
|
||||||
|
},
|
||||||
|
"at": int(time.time()),
|
||||||
|
}
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
if action == "start":
|
||||||
|
await _bulk_guest_status(request, "running")
|
||||||
|
elif action == "shutdown":
|
||||||
|
await _bulk_guest_status(request, "stopped")
|
||||||
|
elif action == "suspend":
|
||||||
|
await _bulk_guest_status(request, "paused")
|
||||||
|
elif action == "migrate":
|
||||||
|
target = str(payload.get("target") or "")
|
||||||
|
if target:
|
||||||
|
target_row = await database(request).pool.fetchrow(
|
||||||
|
"SELECT id FROM nodes WHERE name=$1", target
|
||||||
|
)
|
||||||
|
if target_row is None:
|
||||||
|
raise ApiError(404, "target node does not exist")
|
||||||
|
vms = payload.get("vms") or payload.get("guests") or ""
|
||||||
|
if isinstance(vms, str) and vms:
|
||||||
|
ids = [part.strip() for part in vms.split(",") if part.strip()]
|
||||||
|
for vmid in ids:
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"""UPDATE resources SET node_id=$2, updated_at=now()
|
||||||
|
WHERE kind IN ('qemu', 'lxc') AND external_id=$1""",
|
||||||
|
vmid,
|
||||||
|
target_row["id"],
|
||||||
|
)
|
||||||
|
return await _cluster_task(request, task_type=f"bulk-{action}", worker=f"bulk{action}")
|
||||||
|
|
||||||
|
async def cluster_ceph_index(
|
||||||
|
_request: Request, _inputs: dict[str, Any]
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
return subdirs("flags", "metadata", "status")
|
||||||
|
|
||||||
|
async def ceph_flags_get(request: Request, _inputs: dict[str, Any]) -> dict[str, int]:
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
ceph = _ceph(metadata)
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
return {str(key): int(value) for key, value in ceph["flags"].items()}
|
||||||
|
|
||||||
|
async def ceph_flags_put(request: Request, inputs: dict[str, Any]) -> dict[str, int]:
|
||||||
|
payload = values(inputs)
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
ceph = _ceph(metadata)
|
||||||
|
flags = dict(ceph["flags"])
|
||||||
|
for key, value in payload.items():
|
||||||
|
if key in {"delete", "digest"}:
|
||||||
|
continue
|
||||||
|
flags[str(key)] = int(value)
|
||||||
|
ceph["flags"] = flags
|
||||||
|
metadata["ceph"] = ceph
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
return {str(key): int(value) for key, value in flags.items()}
|
||||||
|
|
||||||
|
async def ceph_flag_get(request: Request, inputs: dict[str, Any]) -> dict[str, int]:
|
||||||
|
flag = str(values(inputs)["flag"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
ceph = _ceph(metadata)
|
||||||
|
flags = ceph["flags"]
|
||||||
|
if flag not in flags:
|
||||||
|
raise ApiError(404, "ceph flag does not exist")
|
||||||
|
return {flag: int(flags[flag])}
|
||||||
|
|
||||||
|
async def ceph_flag_put(request: Request, inputs: dict[str, Any]) -> dict[str, int]:
|
||||||
|
payload = values(inputs)
|
||||||
|
flag = str(payload["flag"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
ceph = _ceph(metadata)
|
||||||
|
flags = dict(ceph["flags"])
|
||||||
|
if "value" in payload:
|
||||||
|
flags[flag] = int(payload["value"])
|
||||||
|
elif flag in payload:
|
||||||
|
flags[flag] = int(payload[flag])
|
||||||
|
else:
|
||||||
|
flags[flag] = 1
|
||||||
|
ceph["flags"] = flags
|
||||||
|
metadata["ceph"] = ceph
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
return {flag: int(flags[flag])}
|
||||||
|
|
||||||
|
async def ceph_metadata(request: Request, _inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
ceph = _ceph(metadata)
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
return {
|
||||||
|
"version": ceph.get("version") or {"str": "18.2.2", "parts": [18, 2, 2]},
|
||||||
|
"fsid": ceph.get("config", {}).get("fsid")
|
||||||
|
if isinstance(ceph.get("config"), dict)
|
||||||
|
else "pve-simulator-fsid",
|
||||||
|
"initialized": int(bool(ceph.get("initialized", True))),
|
||||||
|
"flags": ceph.get("flags", {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def ha_rule_create(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
rule = str(payload.get("rule") or payload.get("name") or "")
|
||||||
|
if not rule:
|
||||||
|
raise ApiError(400, "parameter verification failed - 'rule' missing")
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
rules = _ha_rules_store(metadata)
|
||||||
|
if any(str(item.get("rule")) == rule for item in rules):
|
||||||
|
raise ApiError(409, "HA rule already exists")
|
||||||
|
entry = {key: value for key, value in payload.items() if key not in {"delete", "digest"}}
|
||||||
|
entry["rule"] = rule
|
||||||
|
entry.setdefault("type", "resource")
|
||||||
|
entry.setdefault("action", "migrate")
|
||||||
|
rules.append(entry)
|
||||||
|
_save_ha_rules(metadata, rules)
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
|
||||||
|
async def ha_rule_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
rule = str(values(inputs)["rule"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
for item in _ha_rules_store(metadata):
|
||||||
|
if str(item.get("rule")) == rule:
|
||||||
|
return dict(item)
|
||||||
|
raise ApiError(404, "HA rule does not exist")
|
||||||
|
|
||||||
|
async def ha_rule_update(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
rule = str(payload["rule"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
rules = _ha_rules_store(metadata)
|
||||||
|
updated: list[dict[str, Any]] = []
|
||||||
|
found = False
|
||||||
|
for item in rules:
|
||||||
|
if str(item.get("rule")) != rule:
|
||||||
|
updated.append(item)
|
||||||
|
continue
|
||||||
|
found = True
|
||||||
|
merged = {
|
||||||
|
**item,
|
||||||
|
**{
|
||||||
|
key: value
|
||||||
|
for key, value in payload.items()
|
||||||
|
if key not in {"rule", "delete", "digest"}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
merged["rule"] = rule
|
||||||
|
updated.append(merged)
|
||||||
|
if not found:
|
||||||
|
raise ApiError(404, "HA rule does not exist")
|
||||||
|
_save_ha_rules(metadata, updated)
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
|
||||||
|
async def ha_rule_delete(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
rule = str(values(inputs)["rule"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
rules = _ha_rules_store(metadata)
|
||||||
|
remaining = [item for item in rules if str(item.get("rule")) != rule]
|
||||||
|
if len(remaining) == len(rules):
|
||||||
|
raise ApiError(404, "HA rule does not exist")
|
||||||
|
_save_ha_rules(metadata, remaining)
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
|
||||||
|
async def node_replication_list(
|
||||||
|
request: Request, inputs: dict[str, Any]
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
jobs = _replication_jobs(metadata)
|
||||||
|
return [
|
||||||
|
job
|
||||||
|
for job in jobs
|
||||||
|
if str(job.get("source") or job.get("node") or node) == node
|
||||||
|
or job.get("source") is None
|
||||||
|
]
|
||||||
|
|
||||||
|
async def node_replication_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
job_id = str(values(inputs)["id"])
|
||||||
|
await require_node(request, node)
|
||||||
|
for job in _replication_jobs(await cluster_metadata(request)):
|
||||||
|
if str(job.get("id")) == job_id:
|
||||||
|
return dict(job)
|
||||||
|
raise ApiError(404, "replication job does not exist")
|
||||||
|
|
||||||
|
async def node_replication_log(
|
||||||
|
request: Request, inputs: dict[str, Any]
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
job = await node_replication_get(request, inputs)
|
||||||
|
log = job.get("log")
|
||||||
|
if isinstance(log, list):
|
||||||
|
return [dict(item) for item in log if isinstance(item, dict)]
|
||||||
|
return [{"t": int(time.time()), "n": 0, "msg": f"replication idle for {job.get('id')}"}]
|
||||||
|
|
||||||
|
async def node_replication_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
job = await node_replication_get(request, inputs)
|
||||||
|
return {
|
||||||
|
"id": job.get("id"),
|
||||||
|
"last_sync": job.get("last_sync", 0),
|
||||||
|
"duration": job.get("duration", 0),
|
||||||
|
"fail_count": job.get("fail_count", 0),
|
||||||
|
"error": job.get("error", ""),
|
||||||
|
"state": job.get("state", "OK"),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def node_replication_schedule_now(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
job_id = str(values(inputs)["id"])
|
||||||
|
await require_node(request, node)
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
jobs = _replication_jobs(metadata)
|
||||||
|
found = False
|
||||||
|
for job in jobs:
|
||||||
|
if str(job.get("id")) != job_id:
|
||||||
|
continue
|
||||||
|
found = True
|
||||||
|
job["last_sync"] = int(time.time())
|
||||||
|
job["state"] = "OK"
|
||||||
|
job["schedule_now"] = 1
|
||||||
|
if not found:
|
||||||
|
raise ApiError(404, "replication job does not exist")
|
||||||
|
metadata["replication"] = jobs
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
|
||||||
|
registry.register("/cluster/jobs", "GET", jobs_index)
|
||||||
|
registry.register("/cluster/jobs/realm-sync", "GET", realm_sync_list)
|
||||||
|
registry.register("/cluster/jobs/realm-sync/{id}", "GET", realm_sync_get)
|
||||||
|
registry.register("/cluster/jobs/realm-sync/{id}", "POST", realm_sync_create)
|
||||||
|
registry.register("/cluster/jobs/realm-sync/{id}", "PUT", realm_sync_update)
|
||||||
|
registry.register("/cluster/jobs/realm-sync/{id}", "DELETE", realm_sync_delete)
|
||||||
|
registry.register("/cluster/jobs/schedule-analyze", "GET", schedule_analyze)
|
||||||
|
|
||||||
|
registry.register("/cluster/metrics", "GET", metrics_index)
|
||||||
|
registry.register("/cluster/metrics/export", "GET", metrics_export)
|
||||||
|
registry.register("/cluster/metrics/server", "GET", metrics_server_list)
|
||||||
|
registry.register("/cluster/metrics/server/{id}", "GET", metrics_server_get)
|
||||||
|
registry.register("/cluster/metrics/server/{id}", "POST", metrics_server_create)
|
||||||
|
registry.register("/cluster/metrics/server/{id}", "PUT", metrics_server_update)
|
||||||
|
registry.register("/cluster/metrics/server/{id}", "DELETE", metrics_server_delete)
|
||||||
|
|
||||||
|
registry.register("/cluster/qemu", "GET", qemu_index)
|
||||||
|
registry.register("/cluster/qemu/cpu-flags", "GET", qemu_cpu_flags)
|
||||||
|
registry.register("/cluster/qemu/custom-cpu-models", "GET", cpu_models_list)
|
||||||
|
registry.register("/cluster/qemu/custom-cpu-models", "POST", cpu_models_create)
|
||||||
|
registry.register("/cluster/qemu/custom-cpu-models/{cputype}", "GET", cpu_models_get)
|
||||||
|
registry.register("/cluster/qemu/custom-cpu-models/{cputype}", "PUT", cpu_models_update)
|
||||||
|
registry.register("/cluster/qemu/custom-cpu-models/{cputype}", "DELETE", cpu_models_delete)
|
||||||
|
|
||||||
|
registry.register("/cluster/bulk-action", "GET", bulk_action_index)
|
||||||
|
registry.register("/cluster/bulk-action/guest", "GET", bulk_guest_index)
|
||||||
|
registry.register(
|
||||||
|
"/cluster/bulk-action/guest/migrate",
|
||||||
|
"POST",
|
||||||
|
lambda request, inputs: bulk_guest_action(request, inputs, "migrate"),
|
||||||
|
)
|
||||||
|
registry.register(
|
||||||
|
"/cluster/bulk-action/guest/shutdown",
|
||||||
|
"POST",
|
||||||
|
lambda request, inputs: bulk_guest_action(request, inputs, "shutdown"),
|
||||||
|
)
|
||||||
|
registry.register(
|
||||||
|
"/cluster/bulk-action/guest/start",
|
||||||
|
"POST",
|
||||||
|
lambda request, inputs: bulk_guest_action(request, inputs, "start"),
|
||||||
|
)
|
||||||
|
registry.register(
|
||||||
|
"/cluster/bulk-action/guest/suspend",
|
||||||
|
"POST",
|
||||||
|
lambda request, inputs: bulk_guest_action(request, inputs, "suspend"),
|
||||||
|
)
|
||||||
|
|
||||||
|
registry.register("/cluster/ceph", "GET", cluster_ceph_index)
|
||||||
|
registry.register("/cluster/ceph/flags", "GET", ceph_flags_get)
|
||||||
|
registry.register("/cluster/ceph/flags", "PUT", ceph_flags_put)
|
||||||
|
registry.register("/cluster/ceph/flags/{flag}", "GET", ceph_flag_get)
|
||||||
|
registry.register("/cluster/ceph/flags/{flag}", "PUT", ceph_flag_put)
|
||||||
|
registry.register("/cluster/ceph/metadata", "GET", ceph_metadata)
|
||||||
|
|
||||||
|
registry.register("/cluster/ha/rules", "POST", ha_rule_create)
|
||||||
|
registry.register("/cluster/ha/rules/{rule}", "GET", ha_rule_get)
|
||||||
|
registry.register("/cluster/ha/rules/{rule}", "PUT", ha_rule_update)
|
||||||
|
registry.register("/cluster/ha/rules/{rule}", "DELETE", ha_rule_delete)
|
||||||
|
|
||||||
|
registry.register("/nodes/{node}/replication", "GET", node_replication_list)
|
||||||
|
registry.register("/nodes/{node}/replication/{id}", "GET", node_replication_get)
|
||||||
|
registry.register("/nodes/{node}/replication/{id}/log", "GET", node_replication_log)
|
||||||
|
registry.register("/nodes/{node}/replication/{id}/status", "GET", node_replication_status)
|
||||||
|
registry.register(
|
||||||
|
"/nodes/{node}/replication/{id}/schedule_now", "POST", node_replication_schedule_now
|
||||||
|
)
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""Shared handler helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from app.api.errors import ApiError
|
||||||
|
from app.db.pool import AsyncpgDatabase
|
||||||
|
|
||||||
|
|
||||||
|
def database(request: Request) -> AsyncpgDatabase:
|
||||||
|
return cast(AsyncpgDatabase, request.app.state.database)
|
||||||
|
|
||||||
|
|
||||||
|
def values(inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return cast(dict[str, Any], inputs["values"])
|
||||||
|
|
||||||
|
|
||||||
|
def require_value(payload: Mapping[str, Any], key: str) -> Any:
|
||||||
|
if key not in payload or payload[key] in {None, ""}:
|
||||||
|
raise ApiError(400, f"parameter '{key}' is required")
|
||||||
|
return payload[key]
|
||||||
|
|
||||||
|
|
||||||
|
def state(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 subdirs(*names: str) -> list[dict[str, str]]:
|
||||||
|
return [{"subdir": name} for name in names]
|
||||||
|
|
||||||
|
|
||||||
|
_SIZE_RE = re.compile(r"^(?P<value>\d+)(?P<unit>[KMGT]?)$", re.IGNORECASE)
|
||||||
|
_UNITS = {"": 1, "K": 2**10, "M": 2**20, "G": 2**30, "T": 2**40}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_size_bytes(value: str) -> int:
|
||||||
|
match = _SIZE_RE.fullmatch(value.strip())
|
||||||
|
if match is None:
|
||||||
|
raise ValueError(f"invalid disk size: {value}")
|
||||||
|
return int(match.group("value")) * _UNITS[match.group("unit").upper()]
|
||||||
|
|
||||||
|
|
||||||
|
def resize_size_bytes(value: str, current: int) -> int:
|
||||||
|
if value.startswith("+"):
|
||||||
|
return current + parse_size_bytes(value[1:])
|
||||||
|
result = parse_size_bytes(value)
|
||||||
|
if result < current:
|
||||||
|
raise ValueError("shrinking disks is not supported")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def replace_disk_size(value: str, size: int) -> str:
|
||||||
|
parts = [part for part in value.split(",") if not part.startswith("size=")]
|
||||||
|
parts.append(f"size={size // 2**30}G" if size % 2**30 == 0 else f"size={size}")
|
||||||
|
return ",".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def disk_size_bytes(value: str) -> int:
|
||||||
|
for part in value.split(","):
|
||||||
|
if part.startswith("size="):
|
||||||
|
return parse_size_bytes(part.removeprefix("size="))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
async def require_node(request: Request, node: str) -> None:
|
||||||
|
exists = await database(request).pool.fetchval(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)",
|
||||||
|
node,
|
||||||
|
)
|
||||||
|
if not exists:
|
||||||
|
raise ApiError(404, "node does not exist")
|
||||||
|
|
||||||
|
|
||||||
|
async def cluster_metadata(request: Request) -> dict[str, Any]:
|
||||||
|
from app.simulation.seed import CLUSTER_ID
|
||||||
|
|
||||||
|
row = await database(request).pool.fetchrow(
|
||||||
|
"SELECT metadata FROM clusters WHERE id=$1",
|
||||||
|
CLUSTER_ID,
|
||||||
|
)
|
||||||
|
return state(row["metadata"]) if row is not None else {}
|
||||||
|
|
||||||
|
|
||||||
|
async def save_cluster_metadata(request: Request, metadata: dict[str, Any]) -> None:
|
||||||
|
from app.simulation.seed import CLUSTER_ID
|
||||||
|
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"UPDATE clusters SET metadata=$2::jsonb, updated_at=now() WHERE id=$1",
|
||||||
|
CLUSTER_ID,
|
||||||
|
json.dumps(metadata, sort_keys=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def node_metadata(request: Request, node: str) -> dict[str, Any]:
|
||||||
|
row = await database(request).pool.fetchrow(
|
||||||
|
"SELECT metadata FROM nodes WHERE name=$1",
|
||||||
|
node,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "node does not exist")
|
||||||
|
return state(row["metadata"])
|
||||||
|
|
||||||
|
|
||||||
|
async def save_node_metadata(request: Request, node: str, metadata: dict[str, Any]) -> None:
|
||||||
|
status = await database(request).pool.execute(
|
||||||
|
"UPDATE nodes SET metadata=$2::jsonb, updated_at=now() WHERE name=$1",
|
||||||
|
node,
|
||||||
|
json.dumps(metadata, sort_keys=True),
|
||||||
|
)
|
||||||
|
if status != "UPDATE 1":
|
||||||
|
raise ApiError(404, "node does not exist")
|
||||||
|
|
||||||
|
|
||||||
|
def storage_payload(row: Any) -> dict[str, Any]:
|
||||||
|
config = state(row["config"])
|
||||||
|
content = config.get("content", [])
|
||||||
|
if isinstance(content, list):
|
||||||
|
content_str = ",".join(str(item) for item in content)
|
||||||
|
else:
|
||||||
|
content_str = str(content)
|
||||||
|
total = int(row["capacity_bytes"] or 0)
|
||||||
|
used = int(row["used_bytes"] or 0)
|
||||||
|
avail = max(total - used, 0)
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"storage": str(row["storage_id"]),
|
||||||
|
"type": str(row["storage_type"]),
|
||||||
|
"shared": int(bool(row["shared"])),
|
||||||
|
"content": content_str,
|
||||||
|
"active": 1,
|
||||||
|
"enabled": 1,
|
||||||
|
"total": total,
|
||||||
|
"used": used,
|
||||||
|
"avail": avail,
|
||||||
|
}
|
||||||
|
if total:
|
||||||
|
payload["used_fraction"] = used / total
|
||||||
|
return payload
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""First read/login semantic service handlers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from app.api.errors import ApiError
|
||||||
|
from app.api.registry import HandlerRegistry
|
||||||
|
from app.config import Settings
|
||||||
|
from app.contracts.runtime import runtime_version_payload
|
||||||
|
from app.db.pool import AsyncpgDatabase
|
||||||
|
from app.handlers.access import register_access_handlers
|
||||||
|
from app.handlers.acme import register_acme_handlers
|
||||||
|
from app.handlers.backup import register_backup_handlers
|
||||||
|
from app.handlers.ceph import register_ceph_handlers
|
||||||
|
from app.handlers.cluster import register_cluster_handlers
|
||||||
|
from app.handlers.cluster_config import register_cluster_config_handlers
|
||||||
|
from app.handlers.cluster_extra import register_cluster_extra_handlers
|
||||||
|
from app.handlers.common import require_node, subdirs
|
||||||
|
from app.handlers.firewall import register_firewall_handlers
|
||||||
|
from app.handlers.ha import register_ha_handlers
|
||||||
|
from app.handlers.legacy_aliases import register_legacy_aliases
|
||||||
|
from app.handlers.lxc import register_lxc_handlers
|
||||||
|
from app.handlers.mapping import register_mapping_handlers
|
||||||
|
from app.handlers.nodes import register_node_ops_handlers
|
||||||
|
from app.handlers.nodes_extra import register_nodes_extra_handlers
|
||||||
|
from app.handlers.notifications import register_notifications_handlers
|
||||||
|
from app.handlers.pools import register_pool_handlers
|
||||||
|
from app.handlers.qemu import register_qemu_handlers
|
||||||
|
from app.handlers.sdn import register_sdn_handlers
|
||||||
|
from app.handlers.storage import register_storage_handlers
|
||||||
|
from app.security.auth import csrf_token, issue_ticket, verify_secret
|
||||||
|
|
||||||
|
|
||||||
|
def _database(request: Request) -> AsyncpgDatabase:
|
||||||
|
return cast(AsyncpgDatabase, request.app.state.database)
|
||||||
|
|
||||||
|
|
||||||
|
def build_core_handlers(settings: Settings) -> HandlerRegistry:
|
||||||
|
registry = HandlerRegistry()
|
||||||
|
|
||||||
|
async def version(request: Request, _inputs: dict[str, Any]) -> dict[str, str]:
|
||||||
|
return runtime_version_payload(request)
|
||||||
|
|
||||||
|
async def login(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
values = cast(dict[str, Any], inputs["values"])
|
||||||
|
username = str(values["username"])
|
||||||
|
password = str(values["password"])
|
||||||
|
row = await _database(request).pool.fetchrow(
|
||||||
|
"SELECT name, password_hash FROM principals WHERE name=$1", username
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
row is None
|
||||||
|
or row["password_hash"] is None
|
||||||
|
or not verify_secret(password, str(row["password_hash"]))
|
||||||
|
):
|
||||||
|
raise ApiError(401, "authentication failure")
|
||||||
|
key = settings.ticket_signing_key.get_secret_value().encode()
|
||||||
|
ticket = issue_ticket(username, key)
|
||||||
|
return {
|
||||||
|
"username": username,
|
||||||
|
"ticket": ticket,
|
||||||
|
"CSRFPreventionToken": csrf_token(ticket, key),
|
||||||
|
"cap": {"vms": {"VM.Audit": 1, "VM.PowerMgmt": 1}},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def nodes(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
rows = await _database(request).pool.fetch(
|
||||||
|
"SELECT name AS node, status FROM nodes ORDER BY name"
|
||||||
|
)
|
||||||
|
return [{"node": str(row["node"]), "status": str(row["status"])} for row in rows]
|
||||||
|
|
||||||
|
async def node_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
node = str(cast(dict[str, Any], inputs["values"])["node"])
|
||||||
|
row = await _database(request).pool.fetchrow(
|
||||||
|
"SELECT name, status FROM nodes WHERE name=$1", node
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "node does not exist")
|
||||||
|
return {
|
||||||
|
"status": str(row["status"]),
|
||||||
|
"node": str(row["name"]),
|
||||||
|
"uptime": 0,
|
||||||
|
"cpu": 0.0,
|
||||||
|
"memory": {"used": 0, "total": 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def resources(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
rows = await _database(request).pool.fetch(
|
||||||
|
"""SELECT r.kind AS type, r.external_id, r.state, n.name AS node
|
||||||
|
FROM resources r JOIN nodes n ON n.id=r.node_id
|
||||||
|
ORDER BY r.kind, r.external_id"""
|
||||||
|
)
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
raw_state = row["state"]
|
||||||
|
state = json.loads(raw_state) if isinstance(raw_state, str) else dict(raw_state)
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"type": str(row["type"]),
|
||||||
|
"id": f"{row['type']}/{row['external_id']}",
|
||||||
|
"node": str(row["node"]),
|
||||||
|
**state,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def node_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
node = str(cast(dict[str, Any], inputs["values"])["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
return subdirs(
|
||||||
|
"apt",
|
||||||
|
"ceph",
|
||||||
|
"disks",
|
||||||
|
"firewall",
|
||||||
|
"lxc",
|
||||||
|
"network",
|
||||||
|
"qemu",
|
||||||
|
"services",
|
||||||
|
"status",
|
||||||
|
"storage",
|
||||||
|
"tasks",
|
||||||
|
"version",
|
||||||
|
"vzdump",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def node_version(request: Request, inputs: dict[str, Any]) -> dict[str, str]:
|
||||||
|
node = str(cast(dict[str, Any], inputs["values"])["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
return runtime_version_payload(request)
|
||||||
|
|
||||||
|
registry.register("/version", "GET", version)
|
||||||
|
registry.register("/access/ticket", "POST", login)
|
||||||
|
registry.register("/nodes", "GET", nodes)
|
||||||
|
registry.register("/nodes/{node}", "GET", node_index)
|
||||||
|
registry.register("/nodes/{node}/status", "GET", node_status)
|
||||||
|
registry.register("/nodes/{node}/version", "GET", node_version)
|
||||||
|
registry.register("/cluster/resources", "GET", resources)
|
||||||
|
register_access_handlers(registry)
|
||||||
|
register_cluster_handlers(registry)
|
||||||
|
register_notifications_handlers(registry)
|
||||||
|
register_mapping_handlers(registry)
|
||||||
|
register_acme_handlers(registry)
|
||||||
|
register_cluster_config_handlers(registry)
|
||||||
|
register_sdn_handlers(registry)
|
||||||
|
register_storage_handlers(registry)
|
||||||
|
|
||||||
|
register_pool_handlers(registry)
|
||||||
|
register_ceph_handlers(registry)
|
||||||
|
register_backup_handlers(registry)
|
||||||
|
register_ha_handlers(registry)
|
||||||
|
register_node_ops_handlers(registry)
|
||||||
|
register_firewall_handlers(registry)
|
||||||
|
register_qemu_handlers(registry)
|
||||||
|
register_lxc_handlers(registry)
|
||||||
|
register_nodes_extra_handlers(registry)
|
||||||
|
register_cluster_extra_handlers(registry)
|
||||||
|
register_legacy_aliases(registry)
|
||||||
|
return registry
|
||||||
@@ -0,0 +1,526 @@
|
|||||||
|
"""Firewall handlers backed by cluster metadata."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from app.api.errors import ApiError
|
||||||
|
from app.api.registry import HandlerRegistry
|
||||||
|
from app.handlers.common import database, require_node, state, subdirs, values
|
||||||
|
from app.simulation.seed import CLUSTER_ID
|
||||||
|
|
||||||
|
DEFAULT_OPTIONS = {
|
||||||
|
"enable": 1,
|
||||||
|
"policy_in": "DROP",
|
||||||
|
"policy_out": "ACCEPT",
|
||||||
|
"log_level_in": "nolog",
|
||||||
|
"log_level_out": "nolog",
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_MACROS = [
|
||||||
|
{"macro": "SSH", "descr": "Secure Shell"},
|
||||||
|
{"macro": "HTTPS", "descr": "Secure web server"},
|
||||||
|
{"macro": "HTTP", "descr": "Web server"},
|
||||||
|
]
|
||||||
|
|
||||||
|
ScopeFn = Callable[[dict[str, Any]], str]
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_firewall(request: Request) -> dict[str, Any]:
|
||||||
|
row = await database(request).pool.fetchrow(
|
||||||
|
"SELECT metadata FROM clusters WHERE id=$1",
|
||||||
|
CLUSTER_ID,
|
||||||
|
)
|
||||||
|
metadata = state(row["metadata"]) if row is not None else {}
|
||||||
|
firewall = metadata.get("firewall")
|
||||||
|
return dict(firewall) if isinstance(firewall, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
async def _save_firewall(request: Request, firewall: dict[str, Any]) -> None:
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"""UPDATE clusters SET metadata = jsonb_set(
|
||||||
|
COALESCE(metadata, '{}'::jsonb), '{firewall}', $2::jsonb, true
|
||||||
|
), updated_at=now() WHERE id=$1""",
|
||||||
|
CLUSTER_ID,
|
||||||
|
json.dumps(firewall, sort_keys=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _scope_data(firewall: dict[str, Any], scope: str) -> dict[str, Any]:
|
||||||
|
scopes = firewall.setdefault("scopes", {})
|
||||||
|
if scope not in scopes or not isinstance(scopes[scope], dict):
|
||||||
|
scopes[scope] = {
|
||||||
|
"options": dict(DEFAULT_OPTIONS),
|
||||||
|
"rules": [],
|
||||||
|
"aliases": {},
|
||||||
|
"ipset": {},
|
||||||
|
"groups": {},
|
||||||
|
"log": [],
|
||||||
|
}
|
||||||
|
section = scopes[scope]
|
||||||
|
section.setdefault("options", dict(DEFAULT_OPTIONS))
|
||||||
|
section.setdefault("rules", [])
|
||||||
|
section.setdefault("aliases", {})
|
||||||
|
section.setdefault("ipset", {})
|
||||||
|
section.setdefault("groups", {})
|
||||||
|
section.setdefault("log", [])
|
||||||
|
return cast(dict[str, Any], section)
|
||||||
|
|
||||||
|
|
||||||
|
def register_firewall_handlers(registry: HandlerRegistry) -> None:
|
||||||
|
def register_scope(
|
||||||
|
base: str,
|
||||||
|
scope_fn: ScopeFn,
|
||||||
|
*,
|
||||||
|
require_node_name: bool = False,
|
||||||
|
include_macros: bool = False,
|
||||||
|
include_groups: bool = False,
|
||||||
|
) -> None:
|
||||||
|
async def _ready(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = values(inputs)
|
||||||
|
if require_node_name:
|
||||||
|
await require_node(request, str(payload["node"]))
|
||||||
|
return payload
|
||||||
|
|
||||||
|
async def index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
await _ready(request, inputs)
|
||||||
|
names = ["aliases", "ipset", "log", "options", "refs", "rules"]
|
||||||
|
if include_groups:
|
||||||
|
names.insert(2, "groups")
|
||||||
|
if include_macros:
|
||||||
|
names.append("macros")
|
||||||
|
return subdirs(*names)
|
||||||
|
|
||||||
|
async def options_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = await _ready(request, inputs)
|
||||||
|
firewall = await _load_firewall(request)
|
||||||
|
return dict(_scope_data(firewall, scope_fn(payload)).get("options", DEFAULT_OPTIONS))
|
||||||
|
|
||||||
|
async def options_put(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = await _ready(request, inputs)
|
||||||
|
firewall = await _load_firewall(request)
|
||||||
|
section = _scope_data(firewall, scope_fn(payload))
|
||||||
|
current = dict(section.get("options", DEFAULT_OPTIONS))
|
||||||
|
for key, value in payload.items():
|
||||||
|
if key in {"node", "vmid", "delete", "digest"}:
|
||||||
|
continue
|
||||||
|
current[key] = value
|
||||||
|
section["options"] = current
|
||||||
|
await _save_firewall(request, firewall)
|
||||||
|
return current
|
||||||
|
|
||||||
|
async def rules_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
payload = await _ready(request, inputs)
|
||||||
|
firewall = await _load_firewall(request)
|
||||||
|
rules = _scope_data(firewall, scope_fn(payload)).get("rules", [])
|
||||||
|
return list(rules) if isinstance(rules, list) else []
|
||||||
|
|
||||||
|
async def rules_create(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = await _ready(request, inputs)
|
||||||
|
firewall = await _load_firewall(request)
|
||||||
|
section = _scope_data(firewall, scope_fn(payload))
|
||||||
|
rules = section.setdefault("rules", [])
|
||||||
|
if not isinstance(rules, list):
|
||||||
|
rules = section["rules"] = []
|
||||||
|
rule = {
|
||||||
|
key: value for key, value in payload.items() if key not in {"node", "vmid", "pos"}
|
||||||
|
}
|
||||||
|
rule["pos"] = len(rules)
|
||||||
|
rules.append(rule)
|
||||||
|
await _save_firewall(request, firewall)
|
||||||
|
|
||||||
|
async def rule_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
pos = int(values(inputs)["pos"])
|
||||||
|
rules = await rules_list(request, inputs)
|
||||||
|
if pos < 0 or pos >= len(rules):
|
||||||
|
raise ApiError(404, "firewall rule does not exist")
|
||||||
|
return dict(rules[pos])
|
||||||
|
|
||||||
|
async def rule_update(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = await _ready(request, inputs)
|
||||||
|
pos = int(payload["pos"])
|
||||||
|
firewall = await _load_firewall(request)
|
||||||
|
rules = _scope_data(firewall, scope_fn(payload)).setdefault("rules", [])
|
||||||
|
if not isinstance(rules, list) or pos < 0 or pos >= len(rules):
|
||||||
|
raise ApiError(404, "firewall rule does not exist")
|
||||||
|
rules[pos] = {
|
||||||
|
**rules[pos],
|
||||||
|
**{k: v for k, v in payload.items() if k not in {"node", "vmid"}},
|
||||||
|
}
|
||||||
|
await _save_firewall(request, firewall)
|
||||||
|
|
||||||
|
async def rule_delete(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = await _ready(request, inputs)
|
||||||
|
pos = int(payload["pos"])
|
||||||
|
firewall = await _load_firewall(request)
|
||||||
|
rules = _scope_data(firewall, scope_fn(payload)).setdefault("rules", [])
|
||||||
|
if not isinstance(rules, list) or pos < 0 or pos >= len(rules):
|
||||||
|
raise ApiError(404, "firewall rule does not exist")
|
||||||
|
del rules[pos]
|
||||||
|
for index, rule in enumerate(rules):
|
||||||
|
if isinstance(rule, dict):
|
||||||
|
rule["pos"] = index
|
||||||
|
await _save_firewall(request, firewall)
|
||||||
|
|
||||||
|
async def aliases_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
payload = await _ready(request, inputs)
|
||||||
|
firewall = await _load_firewall(request)
|
||||||
|
aliases = _scope_data(firewall, scope_fn(payload)).get("aliases", {})
|
||||||
|
if not isinstance(aliases, dict):
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
{"name": name, **{k: v for k, v in data.items() if k != "name"}}
|
||||||
|
for name, data in sorted(aliases.items())
|
||||||
|
if isinstance(data, dict)
|
||||||
|
]
|
||||||
|
|
||||||
|
async def aliases_create(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = await _ready(request, inputs)
|
||||||
|
name = str(payload["name"])
|
||||||
|
firewall = await _load_firewall(request)
|
||||||
|
section = _scope_data(firewall, scope_fn(payload))
|
||||||
|
aliases = section.setdefault("aliases", {})
|
||||||
|
if name in aliases:
|
||||||
|
raise ApiError(400, f"alias '{name}' already exists")
|
||||||
|
aliases[name] = {
|
||||||
|
"name": name,
|
||||||
|
"cidr": str(payload.get("cidr") or ""),
|
||||||
|
"comment": str(payload.get("comment") or ""),
|
||||||
|
}
|
||||||
|
await _save_firewall(request, firewall)
|
||||||
|
|
||||||
|
async def aliases_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = await _ready(request, inputs)
|
||||||
|
name = str(payload["name"])
|
||||||
|
firewall = await _load_firewall(request)
|
||||||
|
alias = _scope_data(firewall, scope_fn(payload)).get("aliases", {}).get(name)
|
||||||
|
if not isinstance(alias, dict):
|
||||||
|
raise ApiError(404, "alias does not exist")
|
||||||
|
return dict(alias)
|
||||||
|
|
||||||
|
async def aliases_update(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = await _ready(request, inputs)
|
||||||
|
name = str(payload["name"])
|
||||||
|
firewall = await _load_firewall(request)
|
||||||
|
aliases = _scope_data(firewall, scope_fn(payload)).setdefault("aliases", {})
|
||||||
|
if name not in aliases or not isinstance(aliases[name], dict):
|
||||||
|
raise ApiError(404, "alias does not exist")
|
||||||
|
current = dict(aliases[name])
|
||||||
|
if payload.get("rename"):
|
||||||
|
new_name = str(payload["rename"])
|
||||||
|
if new_name in aliases and new_name != name:
|
||||||
|
raise ApiError(400, f"alias '{new_name}' already exists")
|
||||||
|
del aliases[name]
|
||||||
|
name = new_name
|
||||||
|
current["name"] = new_name
|
||||||
|
for key in ("cidr", "comment"):
|
||||||
|
if key in payload:
|
||||||
|
current[key] = payload[key]
|
||||||
|
aliases[name] = current
|
||||||
|
await _save_firewall(request, firewall)
|
||||||
|
|
||||||
|
async def aliases_delete(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = await _ready(request, inputs)
|
||||||
|
name = str(payload["name"])
|
||||||
|
firewall = await _load_firewall(request)
|
||||||
|
aliases = _scope_data(firewall, scope_fn(payload)).setdefault("aliases", {})
|
||||||
|
if name not in aliases:
|
||||||
|
raise ApiError(404, "alias does not exist")
|
||||||
|
del aliases[name]
|
||||||
|
await _save_firewall(request, firewall)
|
||||||
|
|
||||||
|
async def ipset_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
payload = await _ready(request, inputs)
|
||||||
|
firewall = await _load_firewall(request)
|
||||||
|
ipsets = _scope_data(firewall, scope_fn(payload)).get("ipset", {})
|
||||||
|
if not isinstance(ipsets, dict):
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
{"name": name, "comment": data.get("comment", "")}
|
||||||
|
for name, data in sorted(ipsets.items())
|
||||||
|
if isinstance(data, dict)
|
||||||
|
]
|
||||||
|
|
||||||
|
async def ipset_create(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = await _ready(request, inputs)
|
||||||
|
name = str(payload["name"])
|
||||||
|
firewall = await _load_firewall(request)
|
||||||
|
ipsets = _scope_data(firewall, scope_fn(payload)).setdefault("ipset", {})
|
||||||
|
if name in ipsets:
|
||||||
|
raise ApiError(400, f"ipset '{name}' already exists")
|
||||||
|
ipsets[name] = {
|
||||||
|
"name": name,
|
||||||
|
"comment": str(payload.get("comment") or ""),
|
||||||
|
"entries": {},
|
||||||
|
}
|
||||||
|
await _save_firewall(request, firewall)
|
||||||
|
|
||||||
|
async def ipset_get(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
payload = await _ready(request, inputs)
|
||||||
|
name = str(payload["name"])
|
||||||
|
firewall = await _load_firewall(request)
|
||||||
|
ipset = _scope_data(firewall, scope_fn(payload)).get("ipset", {}).get(name)
|
||||||
|
if not isinstance(ipset, dict):
|
||||||
|
raise ApiError(404, "ipset does not exist")
|
||||||
|
entries = ipset.get("entries", {})
|
||||||
|
if not isinstance(entries, dict):
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
{"cidr": cidr, **{k: v for k, v in data.items() if k != "cidr"}}
|
||||||
|
for cidr, data in sorted(entries.items())
|
||||||
|
if isinstance(data, dict)
|
||||||
|
]
|
||||||
|
|
||||||
|
async def ipset_delete(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = await _ready(request, inputs)
|
||||||
|
name = str(payload["name"])
|
||||||
|
firewall = await _load_firewall(request)
|
||||||
|
ipsets = _scope_data(firewall, scope_fn(payload)).setdefault("ipset", {})
|
||||||
|
if name not in ipsets:
|
||||||
|
raise ApiError(404, "ipset does not exist")
|
||||||
|
del ipsets[name]
|
||||||
|
await _save_firewall(request, firewall)
|
||||||
|
|
||||||
|
async def ipset_entry_create(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = await _ready(request, inputs)
|
||||||
|
name = str(payload["name"])
|
||||||
|
cidr = str(payload["cidr"])
|
||||||
|
firewall = await _load_firewall(request)
|
||||||
|
ipsets = _scope_data(firewall, scope_fn(payload)).setdefault("ipset", {})
|
||||||
|
if name not in ipsets or not isinstance(ipsets[name], dict):
|
||||||
|
raise ApiError(404, "ipset does not exist")
|
||||||
|
entries = ipsets[name].setdefault("entries", {})
|
||||||
|
if cidr in entries:
|
||||||
|
raise ApiError(400, f"ip '{cidr}' already exists in ipset")
|
||||||
|
entries[cidr] = {
|
||||||
|
"cidr": cidr,
|
||||||
|
"comment": str(payload.get("comment") or ""),
|
||||||
|
"nomatch": int(bool(payload.get("nomatch"))),
|
||||||
|
}
|
||||||
|
await _save_firewall(request, firewall)
|
||||||
|
|
||||||
|
async def ipset_entry_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = await _ready(request, inputs)
|
||||||
|
name = str(payload["name"])
|
||||||
|
cidr = str(payload["cidr"])
|
||||||
|
firewall = await _load_firewall(request)
|
||||||
|
ipset = _scope_data(firewall, scope_fn(payload)).get("ipset", {}).get(name)
|
||||||
|
if not isinstance(ipset, dict):
|
||||||
|
raise ApiError(404, "ipset does not exist")
|
||||||
|
entry = ipset.get("entries", {}).get(cidr)
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
raise ApiError(404, "ipset entry does not exist")
|
||||||
|
return dict(entry)
|
||||||
|
|
||||||
|
async def ipset_entry_update(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = await _ready(request, inputs)
|
||||||
|
name = str(payload["name"])
|
||||||
|
cidr = str(payload["cidr"])
|
||||||
|
firewall = await _load_firewall(request)
|
||||||
|
ipsets = _scope_data(firewall, scope_fn(payload)).setdefault("ipset", {})
|
||||||
|
if name not in ipsets or not isinstance(ipsets[name], dict):
|
||||||
|
raise ApiError(404, "ipset does not exist")
|
||||||
|
entries = ipsets[name].setdefault("entries", {})
|
||||||
|
if cidr not in entries:
|
||||||
|
raise ApiError(404, "ipset entry does not exist")
|
||||||
|
current = dict(entries[cidr])
|
||||||
|
if "comment" in payload:
|
||||||
|
current["comment"] = payload["comment"]
|
||||||
|
if "nomatch" in payload:
|
||||||
|
current["nomatch"] = int(bool(payload.get("nomatch")))
|
||||||
|
entries[cidr] = current
|
||||||
|
await _save_firewall(request, firewall)
|
||||||
|
|
||||||
|
async def ipset_entry_delete(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = await _ready(request, inputs)
|
||||||
|
name = str(payload["name"])
|
||||||
|
cidr = str(payload["cidr"])
|
||||||
|
firewall = await _load_firewall(request)
|
||||||
|
ipsets = _scope_data(firewall, scope_fn(payload)).setdefault("ipset", {})
|
||||||
|
if name not in ipsets or not isinstance(ipsets[name], dict):
|
||||||
|
raise ApiError(404, "ipset does not exist")
|
||||||
|
entries = ipsets[name].setdefault("entries", {})
|
||||||
|
if cidr not in entries:
|
||||||
|
raise ApiError(404, "ipset entry does not exist")
|
||||||
|
del entries[cidr]
|
||||||
|
await _save_firewall(request, firewall)
|
||||||
|
|
||||||
|
async def refs_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
payload = await _ready(request, inputs)
|
||||||
|
firewall = await _load_firewall(request)
|
||||||
|
section = _scope_data(firewall, scope_fn(payload))
|
||||||
|
refs: list[dict[str, Any]] = []
|
||||||
|
for name in section.get("aliases", {}):
|
||||||
|
refs.append({"type": "alias", "name": name})
|
||||||
|
for name in section.get("ipset", {}):
|
||||||
|
refs.append({"type": "ipset", "name": name})
|
||||||
|
return refs
|
||||||
|
|
||||||
|
async def log_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
payload = await _ready(request, inputs)
|
||||||
|
firewall = await _load_firewall(request)
|
||||||
|
log = _scope_data(firewall, scope_fn(payload)).get("log", [])
|
||||||
|
return list(log) if isinstance(log, list) else []
|
||||||
|
|
||||||
|
async def macros_list(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
return list(DEFAULT_MACROS)
|
||||||
|
|
||||||
|
async def groups_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
payload = await _ready(request, inputs)
|
||||||
|
firewall = await _load_firewall(request)
|
||||||
|
groups = _scope_data(firewall, scope_fn(payload)).get("groups", {})
|
||||||
|
if not isinstance(groups, dict):
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
{"group": name, "comment": data.get("comment", "")}
|
||||||
|
for name, data in sorted(groups.items())
|
||||||
|
if isinstance(data, dict)
|
||||||
|
]
|
||||||
|
|
||||||
|
async def groups_create(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = await _ready(request, inputs)
|
||||||
|
group = str(payload["group"])
|
||||||
|
firewall = await _load_firewall(request)
|
||||||
|
groups = _scope_data(firewall, scope_fn(payload)).setdefault("groups", {})
|
||||||
|
if group in groups:
|
||||||
|
raise ApiError(400, f"security group '{group}' already exists")
|
||||||
|
groups[group] = {"comment": str(payload.get("comment") or ""), "rules": []}
|
||||||
|
await _save_firewall(request, firewall)
|
||||||
|
|
||||||
|
async def group_rules(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
payload = await _ready(request, inputs)
|
||||||
|
group = str(payload["group"])
|
||||||
|
firewall = await _load_firewall(request)
|
||||||
|
groups = _scope_data(firewall, scope_fn(payload)).get("groups", {})
|
||||||
|
if group not in groups or not isinstance(groups[group], dict):
|
||||||
|
raise ApiError(404, "security group does not exist")
|
||||||
|
rules = groups[group].get("rules", [])
|
||||||
|
return list(rules) if isinstance(rules, list) else []
|
||||||
|
|
||||||
|
async def group_delete(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = await _ready(request, inputs)
|
||||||
|
group = str(payload["group"])
|
||||||
|
firewall = await _load_firewall(request)
|
||||||
|
groups = _scope_data(firewall, scope_fn(payload)).setdefault("groups", {})
|
||||||
|
if group not in groups:
|
||||||
|
raise ApiError(404, "security group does not exist")
|
||||||
|
del groups[group]
|
||||||
|
await _save_firewall(request, firewall)
|
||||||
|
|
||||||
|
async def group_rule_create(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = await _ready(request, inputs)
|
||||||
|
group = str(payload["group"])
|
||||||
|
firewall = await _load_firewall(request)
|
||||||
|
groups = _scope_data(firewall, scope_fn(payload)).setdefault("groups", {})
|
||||||
|
if group not in groups or not isinstance(groups[group], dict):
|
||||||
|
raise ApiError(404, "security group does not exist")
|
||||||
|
rules = groups[group].setdefault("rules", [])
|
||||||
|
if not isinstance(rules, list):
|
||||||
|
rules = groups[group]["rules"] = []
|
||||||
|
rule = {
|
||||||
|
key: value
|
||||||
|
for key, value in payload.items()
|
||||||
|
if key not in {"node", "vmid", "group", "pos"}
|
||||||
|
}
|
||||||
|
rule["pos"] = len(rules)
|
||||||
|
rules.append(rule)
|
||||||
|
await _save_firewall(request, firewall)
|
||||||
|
|
||||||
|
async def group_rule_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
rules = await group_rules(request, inputs)
|
||||||
|
pos = int(values(inputs)["pos"])
|
||||||
|
if pos < 0 or pos >= len(rules):
|
||||||
|
raise ApiError(404, "firewall rule does not exist")
|
||||||
|
return dict(rules[pos])
|
||||||
|
|
||||||
|
async def group_rule_update(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = await _ready(request, inputs)
|
||||||
|
group = str(payload["group"])
|
||||||
|
pos = int(payload["pos"])
|
||||||
|
firewall = await _load_firewall(request)
|
||||||
|
groups = _scope_data(firewall, scope_fn(payload)).setdefault("groups", {})
|
||||||
|
if group not in groups or not isinstance(groups[group], dict):
|
||||||
|
raise ApiError(404, "security group does not exist")
|
||||||
|
rules = groups[group].setdefault("rules", [])
|
||||||
|
if not isinstance(rules, list) or pos < 0 or pos >= len(rules):
|
||||||
|
raise ApiError(404, "firewall rule does not exist")
|
||||||
|
rules[pos] = {
|
||||||
|
**rules[pos],
|
||||||
|
**{k: v for k, v in payload.items() if k not in {"node", "vmid", "group"}},
|
||||||
|
}
|
||||||
|
await _save_firewall(request, firewall)
|
||||||
|
|
||||||
|
async def group_rule_delete(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = await _ready(request, inputs)
|
||||||
|
group = str(payload["group"])
|
||||||
|
pos = int(payload["pos"])
|
||||||
|
firewall = await _load_firewall(request)
|
||||||
|
groups = _scope_data(firewall, scope_fn(payload)).setdefault("groups", {})
|
||||||
|
if group not in groups or not isinstance(groups[group], dict):
|
||||||
|
raise ApiError(404, "security group does not exist")
|
||||||
|
rules = groups[group].setdefault("rules", [])
|
||||||
|
if not isinstance(rules, list) or pos < 0 or pos >= len(rules):
|
||||||
|
raise ApiError(404, "firewall rule does not exist")
|
||||||
|
del rules[pos]
|
||||||
|
await _save_firewall(request, firewall)
|
||||||
|
|
||||||
|
registry.register(base, "GET", index)
|
||||||
|
registry.register(f"{base}/options", "GET", options_get)
|
||||||
|
registry.register(f"{base}/options", "PUT", options_put)
|
||||||
|
registry.register(f"{base}/rules", "GET", rules_list)
|
||||||
|
registry.register(f"{base}/rules", "POST", rules_create)
|
||||||
|
registry.register(f"{base}/rules/{{pos}}", "GET", rule_get)
|
||||||
|
registry.register(f"{base}/rules/{{pos}}", "PUT", rule_update)
|
||||||
|
registry.register(f"{base}/rules/{{pos}}", "DELETE", rule_delete)
|
||||||
|
registry.register(f"{base}/aliases", "GET", aliases_list)
|
||||||
|
registry.register(f"{base}/aliases", "POST", aliases_create)
|
||||||
|
registry.register(f"{base}/aliases/{{name}}", "GET", aliases_get)
|
||||||
|
registry.register(f"{base}/aliases/{{name}}", "PUT", aliases_update)
|
||||||
|
registry.register(f"{base}/aliases/{{name}}", "DELETE", aliases_delete)
|
||||||
|
registry.register(f"{base}/ipset", "GET", ipset_list)
|
||||||
|
registry.register(f"{base}/ipset", "POST", ipset_create)
|
||||||
|
registry.register(f"{base}/ipset/{{name}}", "GET", ipset_get)
|
||||||
|
registry.register(f"{base}/ipset/{{name}}", "DELETE", ipset_delete)
|
||||||
|
registry.register(f"{base}/ipset/{{name}}", "POST", ipset_entry_create)
|
||||||
|
registry.register(f"{base}/ipset/{{name}}/{{cidr}}", "GET", ipset_entry_get)
|
||||||
|
registry.register(f"{base}/ipset/{{name}}/{{cidr}}", "PUT", ipset_entry_update)
|
||||||
|
registry.register(f"{base}/ipset/{{name}}/{{cidr}}", "DELETE", ipset_entry_delete)
|
||||||
|
registry.register(f"{base}/refs", "GET", refs_list)
|
||||||
|
registry.register(f"{base}/log", "GET", log_list)
|
||||||
|
if include_macros:
|
||||||
|
registry.register(f"{base}/macros", "GET", macros_list)
|
||||||
|
if include_groups:
|
||||||
|
registry.register(f"{base}/groups", "GET", groups_list)
|
||||||
|
registry.register(f"{base}/groups", "POST", groups_create)
|
||||||
|
registry.register(f"{base}/groups/{{group}}", "GET", group_rules)
|
||||||
|
registry.register(f"{base}/groups/{{group}}", "POST", group_rule_create)
|
||||||
|
registry.register(f"{base}/groups/{{group}}", "DELETE", group_delete)
|
||||||
|
registry.register(f"{base}/groups/{{group}}/{{pos}}", "GET", group_rule_get)
|
||||||
|
registry.register(f"{base}/groups/{{group}}/{{pos}}", "PUT", group_rule_update)
|
||||||
|
registry.register(f"{base}/groups/{{group}}/{{pos}}", "DELETE", group_rule_delete)
|
||||||
|
|
||||||
|
register_scope(
|
||||||
|
"/cluster/firewall",
|
||||||
|
lambda _payload: "cluster",
|
||||||
|
include_macros=True,
|
||||||
|
include_groups=True,
|
||||||
|
)
|
||||||
|
register_scope(
|
||||||
|
"/nodes/{node}/firewall",
|
||||||
|
lambda payload: f"node:{payload['node']}",
|
||||||
|
require_node_name=True,
|
||||||
|
)
|
||||||
|
register_scope(
|
||||||
|
"/nodes/{node}/qemu/{vmid}/firewall",
|
||||||
|
lambda payload: f"qemu:{payload['node']}:{payload['vmid']}",
|
||||||
|
require_node_name=True,
|
||||||
|
)
|
||||||
|
register_scope(
|
||||||
|
"/nodes/{node}/lxc/{vmid}/firewall",
|
||||||
|
lambda payload: f"lxc:{payload['node']}:{payload['vmid']}",
|
||||||
|
require_node_name=True,
|
||||||
|
)
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
"""High availability semantic handlers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from app.api.errors import ApiError
|
||||||
|
from app.api.registry import HandlerRegistry
|
||||||
|
from app.handlers.common import (
|
||||||
|
cluster_metadata,
|
||||||
|
database,
|
||||||
|
save_cluster_metadata,
|
||||||
|
state,
|
||||||
|
subdirs,
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
from app.simulation.seed import CLUSTER_ID, stable_id
|
||||||
|
|
||||||
|
|
||||||
|
def _ha_groups(metadata: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||||
|
groups = metadata.get("ha_groups", {})
|
||||||
|
if not isinstance(groups, dict):
|
||||||
|
return {}
|
||||||
|
return {str(key): dict(value) for key, value in groups.items() if isinstance(value, dict)}
|
||||||
|
|
||||||
|
|
||||||
|
def register_ha_handlers(registry: HandlerRegistry) -> None:
|
||||||
|
async def ha_index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
return subdirs("groups", "resources", "rules", "status")
|
||||||
|
|
||||||
|
async def ha_resources(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
rows = await database(_request).pool.fetch(
|
||||||
|
"""SELECT r.external_id, r.state, n.name AS node
|
||||||
|
FROM resources r JOIN nodes n ON n.id=r.node_id
|
||||||
|
WHERE r.kind='ha' ORDER BY r.external_id"""
|
||||||
|
)
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
payload = state(row["state"])
|
||||||
|
sid = str(row["external_id"])
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"sid": sid,
|
||||||
|
"type": "vm" if sid.startswith("vm:") else "ct",
|
||||||
|
"state": payload.get("state", "started"),
|
||||||
|
"group": payload.get("group"),
|
||||||
|
"node": str(row["node"]),
|
||||||
|
"max_relocate": payload.get("max_relocate", 1),
|
||||||
|
"max_restart": payload.get("max_restart", 1),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def ha_resource_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
sid = str(values(inputs)["sid"])
|
||||||
|
items = await ha_resources(request, inputs)
|
||||||
|
for item in items:
|
||||||
|
if item["sid"] == sid:
|
||||||
|
return item
|
||||||
|
raise ApiError(404, "HA resource does not exist")
|
||||||
|
|
||||||
|
async def ha_resource_create(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
sid = str(payload["sid"])
|
||||||
|
group = str(payload.get("group") or "")
|
||||||
|
exists = await database(request).pool.fetchval(
|
||||||
|
"""SELECT EXISTS(SELECT 1 FROM resources WHERE kind='ha' AND external_id=$1)""",
|
||||||
|
sid,
|
||||||
|
)
|
||||||
|
if exists:
|
||||||
|
raise ApiError(409, "HA resource already exists")
|
||||||
|
guest_kind, _, guest_id = sid.partition(":")
|
||||||
|
if guest_kind not in {"vm", "ct"} or not guest_id.isdigit():
|
||||||
|
raise ApiError(400, "invalid HA resource sid")
|
||||||
|
resource_kind = "qemu" if guest_kind == "vm" else "lxc"
|
||||||
|
guest = await database(request).pool.fetchrow(
|
||||||
|
"""SELECT r.id, n.name FROM resources r JOIN nodes n ON n.id=r.node_id
|
||||||
|
WHERE r.kind=$1 AND r.external_id=$2""",
|
||||||
|
resource_kind,
|
||||||
|
guest_id,
|
||||||
|
)
|
||||||
|
if guest is None:
|
||||||
|
raise ApiError(404, "guest does not exist")
|
||||||
|
node = await database(request).pool.fetchrow(
|
||||||
|
"SELECT id FROM nodes WHERE name=$1",
|
||||||
|
str(guest["name"]),
|
||||||
|
)
|
||||||
|
if node is None:
|
||||||
|
raise ApiError(404, "node does not exist")
|
||||||
|
ha_state = {
|
||||||
|
"state": str(payload.get("state") or "started"),
|
||||||
|
"group": group or None,
|
||||||
|
"max_relocate": int(payload.get("max_relocate") or 1),
|
||||||
|
"max_restart": int(payload.get("max_restart") or 1),
|
||||||
|
}
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"""INSERT INTO resources(id, node_id, cluster_id, kind, external_id, state, metadata)
|
||||||
|
VALUES($1, $2, $3, 'ha', $4, $5::jsonb, '{}'::jsonb)""",
|
||||||
|
stable_id(f"ha:{sid}"),
|
||||||
|
node["id"],
|
||||||
|
CLUSTER_ID,
|
||||||
|
sid,
|
||||||
|
json.dumps(ha_state, sort_keys=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def ha_resource_update(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
sid = str(values(inputs)["sid"])
|
||||||
|
payload = values(inputs)
|
||||||
|
row = await database(request).pool.fetchrow(
|
||||||
|
"SELECT id, state FROM resources WHERE kind='ha' AND external_id=$1",
|
||||||
|
sid,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "HA resource does not exist")
|
||||||
|
current = state(row["state"])
|
||||||
|
updated = {
|
||||||
|
**current,
|
||||||
|
**{
|
||||||
|
key: value
|
||||||
|
for key, value in payload.items()
|
||||||
|
if key not in {"sid", "delete", "digest"}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"UPDATE resources SET state=$2::jsonb, updated_at=now() WHERE id=$1",
|
||||||
|
row["id"],
|
||||||
|
json.dumps(updated, sort_keys=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def ha_resource_delete(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
sid = str(values(inputs)["sid"])
|
||||||
|
status = await database(request).pool.execute(
|
||||||
|
"DELETE FROM resources WHERE kind='ha' AND external_id=$1",
|
||||||
|
sid,
|
||||||
|
)
|
||||||
|
if status != "DELETE 1":
|
||||||
|
raise ApiError(404, "HA resource does not exist")
|
||||||
|
|
||||||
|
async def ha_groups(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
configured = _ha_groups(metadata)
|
||||||
|
result = [
|
||||||
|
{
|
||||||
|
"group": group_id,
|
||||||
|
"nodes": str(payload.get("nodes", "")),
|
||||||
|
"nofailback": int(payload.get("nofailback", 0)),
|
||||||
|
"restricted": int(payload.get("restricted", 0)),
|
||||||
|
"type": "group",
|
||||||
|
"comment": payload.get("comment", ""),
|
||||||
|
}
|
||||||
|
for group_id, payload in sorted(configured.items())
|
||||||
|
]
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
rows = await database(request).pool.fetch(
|
||||||
|
"""SELECT DISTINCT state->>'group' AS group_id
|
||||||
|
FROM resources WHERE kind='ha' AND state ? 'group'
|
||||||
|
ORDER BY 1"""
|
||||||
|
)
|
||||||
|
node_names = await database(request).pool.fetch("SELECT name FROM nodes ORDER BY name")
|
||||||
|
nodes = ",".join(str(row["name"]) for row in node_names) or "pve01"
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"group": str(row["group_id"]),
|
||||||
|
"nodes": nodes,
|
||||||
|
"nofailback": 0,
|
||||||
|
"restricted": 0,
|
||||||
|
"type": "group",
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
if row["group_id"]
|
||||||
|
]
|
||||||
|
|
||||||
|
async def ha_group_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
group = str(values(inputs)["group"])
|
||||||
|
for item in await ha_groups(request, inputs):
|
||||||
|
if item["group"] == group:
|
||||||
|
return item
|
||||||
|
raise ApiError(404, "HA group does not exist")
|
||||||
|
|
||||||
|
async def ha_group_create(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
group = str(payload["group"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
groups = _ha_groups(metadata)
|
||||||
|
if group in groups:
|
||||||
|
raise ApiError(409, "HA group already exists")
|
||||||
|
groups[group] = {
|
||||||
|
"nodes": str(payload.get("nodes") or ""),
|
||||||
|
"nofailback": int(payload.get("nofailback") or 0),
|
||||||
|
"restricted": int(payload.get("restricted") or 0),
|
||||||
|
"comment": str(payload.get("comment") or ""),
|
||||||
|
}
|
||||||
|
metadata["ha_groups"] = groups
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
|
||||||
|
async def ha_group_update(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
group = str(values(inputs)["group"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
groups = _ha_groups(metadata)
|
||||||
|
if group not in groups:
|
||||||
|
raise ApiError(404, "HA group does not exist")
|
||||||
|
payload = values(inputs)
|
||||||
|
groups[group] = {
|
||||||
|
**groups[group],
|
||||||
|
**{
|
||||||
|
key: value
|
||||||
|
for key, value in payload.items()
|
||||||
|
if key not in {"group", "delete", "digest"}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
metadata["ha_groups"] = groups
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
|
||||||
|
async def ha_group_delete(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
group = str(values(inputs)["group"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
groups = _ha_groups(metadata)
|
||||||
|
if group not in groups:
|
||||||
|
raise ApiError(404, "HA group does not exist")
|
||||||
|
del groups[group]
|
||||||
|
metadata["ha_groups"] = groups
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
|
||||||
|
async def ha_status(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
return subdirs("current", "manager_status")
|
||||||
|
|
||||||
|
async def ha_status_current(request: Request, _inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
row = await database(request).pool.fetchrow(
|
||||||
|
"""SELECT count(*) FILTER (WHERE state->>'state' = 'started') AS started,
|
||||||
|
count(*) AS total
|
||||||
|
FROM resources WHERE kind='ha'"""
|
||||||
|
)
|
||||||
|
master = await database(request).pool.fetchval(
|
||||||
|
"SELECT name FROM nodes WHERE status='online' ORDER BY name LIMIT 1"
|
||||||
|
)
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
ha = metadata.get("ha", {}) if isinstance(metadata.get("ha"), dict) else {}
|
||||||
|
return {
|
||||||
|
"quorate": 1,
|
||||||
|
"mode": "active" if ha.get("armed", True) else "disabled",
|
||||||
|
"master_node": str(master or "pve01"),
|
||||||
|
"ha_started": int(row["started"] or 0),
|
||||||
|
"ha_total": int(row["total"] or 0),
|
||||||
|
"armed": 1 if ha.get("armed", True) else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def ha_manager_status(request: Request, _inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
ha = metadata.get("ha", {}) if isinstance(metadata.get("ha"), dict) else {}
|
||||||
|
armed = bool(ha.get("armed", True))
|
||||||
|
return {
|
||||||
|
"manager_status": "active" if armed else "disabled",
|
||||||
|
"quorum": "OK",
|
||||||
|
"armed": 1 if armed else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def ha_rules(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
rules = metadata.get("ha_rules")
|
||||||
|
if isinstance(rules, list) and rules:
|
||||||
|
return [dict(item) for item in rules if isinstance(item, dict)]
|
||||||
|
defaults = [
|
||||||
|
{"rule": "node-fencing", "type": "node", "action": "restart"},
|
||||||
|
{"rule": "service-ha", "type": "resource", "action": "failover"},
|
||||||
|
]
|
||||||
|
metadata["ha_rules"] = defaults
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
return list(defaults)
|
||||||
|
|
||||||
|
async def ha_relocate(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
sid = str(payload["sid"])
|
||||||
|
target = str(payload.get("node") or payload.get("target") or "")
|
||||||
|
if not target:
|
||||||
|
raise ApiError(400, "parameter verification failed - target node missing")
|
||||||
|
ha_row = await database(request).pool.fetchrow(
|
||||||
|
"SELECT id, state FROM resources WHERE kind='ha' AND external_id=$1",
|
||||||
|
sid,
|
||||||
|
)
|
||||||
|
if ha_row is None:
|
||||||
|
raise ApiError(404, "HA resource does not exist")
|
||||||
|
node = await database(request).pool.fetchrow(
|
||||||
|
"SELECT id, name FROM nodes WHERE name=$1",
|
||||||
|
target,
|
||||||
|
)
|
||||||
|
if node is None:
|
||||||
|
raise ApiError(404, "node does not exist")
|
||||||
|
guest_kind, _, guest_id = sid.partition(":")
|
||||||
|
resource_kind = "qemu" if guest_kind == "vm" else "lxc"
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"UPDATE resources SET node_id=$2, updated_at=now() WHERE kind='ha' AND external_id=$1",
|
||||||
|
sid,
|
||||||
|
node["id"],
|
||||||
|
)
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"""UPDATE resources SET node_id=$3, updated_at=now()
|
||||||
|
WHERE kind=$1 AND external_id=$2""",
|
||||||
|
resource_kind,
|
||||||
|
guest_id,
|
||||||
|
node["id"],
|
||||||
|
)
|
||||||
|
current = state(ha_row["state"])
|
||||||
|
current["node"] = target
|
||||||
|
current["state"] = current.get("state") or "started"
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"UPDATE resources SET state=$2::jsonb, updated_at=now() WHERE id=$1",
|
||||||
|
ha_row["id"],
|
||||||
|
json.dumps(current, sort_keys=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def ha_migrate(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
await ha_relocate(request, inputs)
|
||||||
|
|
||||||
|
async def ha_arm(request: Request, _inputs: dict[str, Any]) -> None:
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
ha = dict(metadata.get("ha") or {})
|
||||||
|
ha["armed"] = True
|
||||||
|
metadata["ha"] = ha
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
|
||||||
|
async def ha_disarm(request: Request, _inputs: dict[str, Any]) -> None:
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
ha = dict(metadata.get("ha") or {})
|
||||||
|
ha["armed"] = False
|
||||||
|
metadata["ha"] = ha
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
|
||||||
|
registry.register("/cluster/ha", "GET", ha_index)
|
||||||
|
registry.register("/cluster/ha/groups", "GET", ha_groups)
|
||||||
|
registry.register("/cluster/ha/groups", "POST", ha_group_create)
|
||||||
|
registry.register("/cluster/ha/groups/{group}", "GET", ha_group_get)
|
||||||
|
registry.register("/cluster/ha/groups/{group}", "PUT", ha_group_update)
|
||||||
|
registry.register("/cluster/ha/groups/{group}", "DELETE", ha_group_delete)
|
||||||
|
registry.register("/cluster/ha/resources", "GET", ha_resources)
|
||||||
|
registry.register("/cluster/ha/resources", "POST", ha_resource_create)
|
||||||
|
registry.register("/cluster/ha/resources/{sid}", "GET", ha_resource_get)
|
||||||
|
registry.register("/cluster/ha/resources/{sid}", "PUT", ha_resource_update)
|
||||||
|
registry.register("/cluster/ha/resources/{sid}", "DELETE", ha_resource_delete)
|
||||||
|
registry.register("/cluster/ha/status", "GET", ha_status)
|
||||||
|
registry.register("/cluster/ha/status/current", "GET", ha_status_current)
|
||||||
|
registry.register("/cluster/ha/status/manager_status", "GET", ha_manager_status)
|
||||||
|
registry.register("/cluster/ha/rules", "GET", ha_rules)
|
||||||
|
registry.register("/cluster/ha/resources/{sid}/migrate", "POST", ha_migrate)
|
||||||
|
registry.register("/cluster/ha/resources/{sid}/relocate", "POST", ha_relocate)
|
||||||
|
registry.register("/cluster/ha/status/arm-ha", "POST", ha_arm)
|
||||||
|
registry.register("/cluster/ha/status/disarm-ha", "POST", ha_disarm)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Legacy Proxmox path aliases for older contract snapshots."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.api.registry import HandlerRegistry
|
||||||
|
|
||||||
|
|
||||||
|
def register_legacy_aliases(registry: HandlerRegistry) -> None:
|
||||||
|
"""Register older-path synonyms onto already-registered handlers when present."""
|
||||||
|
|
||||||
|
def alias(old_path: str, old_verb: str, new_path: str, new_verb: str | None = None) -> None:
|
||||||
|
verb = (new_verb or old_verb).upper()
|
||||||
|
handler = registry.get(new_path, verb)
|
||||||
|
if handler is None:
|
||||||
|
return
|
||||||
|
if registry.get(old_path, old_verb) is not None:
|
||||||
|
return
|
||||||
|
registry.register(old_path, old_verb.upper(), handler)
|
||||||
|
|
||||||
|
alias("/access/tfa", "POST", "/access/tfa/{userid}", "POST")
|
||||||
|
alias("/access/tfa", "PUT", "/access/tfa/{userid}/{id}", "PUT")
|
||||||
|
alias("/cluster/backupinfo", "GET", "/cluster/backup-info", "GET")
|
||||||
|
alias(
|
||||||
|
"/cluster/backupinfo/not_backed_up",
|
||||||
|
"GET",
|
||||||
|
"/cluster/backup-info/not-backed-up",
|
||||||
|
"GET",
|
||||||
|
)
|
||||||
|
alias("/nodes/{node}/ceph/config", "GET", "/nodes/{node}/ceph/cfg/raw", "GET")
|
||||||
|
alias("/nodes/{node}/ceph/configdb", "GET", "/nodes/{node}/ceph/cfg/db", "GET")
|
||||||
|
alias("/nodes/{node}/ceph/disks", "GET", "/nodes/{node}/ceph/osd", "GET")
|
||||||
|
alias("/nodes/{node}/ceph/flags", "GET", "/cluster/ceph/flags", "GET")
|
||||||
|
alias("/nodes/{node}/ceph/flags/{flag}", "POST", "/cluster/ceph/flags/{flag}", "PUT")
|
||||||
|
alias("/nodes/{node}/ceph/flags/{flag}", "DELETE", "/cluster/ceph/flags/{flag}", "PUT")
|
||||||
|
alias("/nodes/{node}/ceph/pools", "GET", "/nodes/{node}/ceph/pool", "GET")
|
||||||
|
alias("/nodes/{node}/ceph/pools", "POST", "/nodes/{node}/ceph/pool", "POST")
|
||||||
|
alias("/nodes/{node}/ceph/pools/{name}", "GET", "/nodes/{node}/ceph/pool/{name}", "GET")
|
||||||
|
alias("/nodes/{node}/ceph/pools/{name}", "PUT", "/nodes/{node}/ceph/pool/{name}", "PUT")
|
||||||
|
alias(
|
||||||
|
"/nodes/{node}/ceph/pools/{name}",
|
||||||
|
"DELETE",
|
||||||
|
"/nodes/{node}/ceph/pool/{name}",
|
||||||
|
"DELETE",
|
||||||
|
)
|
||||||
|
alias("/nodes/{node}/cpu", "GET", "/nodes/{node}/capabilities/qemu/cpu", "GET")
|
||||||
|
alias(
|
||||||
|
"/nodes/{node}/hardware/pci/{pciid}",
|
||||||
|
"GET",
|
||||||
|
"/nodes/{node}/hardware/pci/{pci-id-or-mapping}",
|
||||||
|
"GET",
|
||||||
|
)
|
||||||
|
alias(
|
||||||
|
"/nodes/{node}/hardware/pci/{pciid}/mdev",
|
||||||
|
"GET",
|
||||||
|
"/nodes/{node}/hardware/pci/{pci-id-or-mapping}/mdev",
|
||||||
|
"GET",
|
||||||
|
)
|
||||||
|
alias("/nodes/{node}/scan/glusterfs", "GET", "/nodes/{node}/scan/nfs", "GET")
|
||||||
|
alias("/nodes/{node}/scan/usb", "GET", "/nodes/{node}/hardware/usb", "GET")
|
||||||
@@ -0,0 +1,574 @@
|
|||||||
|
"""Persistent LXC semantic handlers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from app.api.errors import ApiError
|
||||||
|
from app.api.registry import HandlerRegistry
|
||||||
|
from app.db.pool import AsyncpgDatabase
|
||||||
|
from app.db.primitives import ConflictError
|
||||||
|
from app.handlers.common import (
|
||||||
|
disk_size_bytes,
|
||||||
|
replace_disk_size,
|
||||||
|
require_node,
|
||||||
|
resize_size_bytes,
|
||||||
|
subdirs,
|
||||||
|
)
|
||||||
|
from app.simulation.transitions import InvalidTransitionError, VmState, plan_transition
|
||||||
|
from app.tasks.repository import TaskRepository
|
||||||
|
from app.tasks.upid import Upid
|
||||||
|
|
||||||
|
|
||||||
|
def _database(request: Request) -> AsyncpgDatabase:
|
||||||
|
return cast(AsyncpgDatabase, request.app.state.database)
|
||||||
|
|
||||||
|
|
||||||
|
def _values(inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return cast(dict[str, Any], inputs["values"])
|
||||||
|
|
||||||
|
|
||||||
|
def _state(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 register_lxc_handlers(registry: HandlerRegistry) -> None:
|
||||||
|
async def lxc_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
node = str(_values(inputs)["node"])
|
||||||
|
rows = await _database(request).pool.fetch(
|
||||||
|
"""SELECT r.external_id::integer AS vmid, r.state
|
||||||
|
FROM resources r JOIN nodes n ON n.id=r.node_id
|
||||||
|
WHERE n.name=$1 AND r.kind='lxc' ORDER BY r.external_id::integer""",
|
||||||
|
node,
|
||||||
|
)
|
||||||
|
return [{"vmid": int(row["vmid"]), **_state(row["state"])} for row in rows]
|
||||||
|
|
||||||
|
async def lxc_config(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
node, vmid = str(_values(inputs)["node"]), str(_values(inputs)["vmid"])
|
||||||
|
row = await _lxc_resource(request, node, vmid)
|
||||||
|
return {"vmid": int(vmid), **_state(row["config"]), **_state(row["state"])}
|
||||||
|
|
||||||
|
async def lxc_current(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await lxc_config(request, inputs)
|
||||||
|
|
||||||
|
async def lxc_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await lxc_config(request, inputs)
|
||||||
|
|
||||||
|
async def mutate(operation: str, request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
values = _values(inputs)
|
||||||
|
node, vmid = str(values["node"]), str(values["vmid"])
|
||||||
|
database = _database(request)
|
||||||
|
row = await database.pool.fetchrow(
|
||||||
|
"""SELECT r.id, r.state FROM resources r JOIN nodes n ON n.id=r.node_id
|
||||||
|
WHERE n.name=$1 AND r.kind='lxc' AND r.external_id=$2""",
|
||||||
|
node,
|
||||||
|
vmid,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "container does not exist")
|
||||||
|
current = str(_state(row["state"]).get("status", "stopped"))
|
||||||
|
try:
|
||||||
|
plan_transition(VmState(current), operation)
|
||||||
|
except (InvalidTransitionError, ValueError) as error:
|
||||||
|
raise ApiError(409, f"cannot {operation} container while it is {current}") from error
|
||||||
|
upid = str(Upid.allocate(node, f"pct{operation}", vmid, str(request.state.principal)))
|
||||||
|
try:
|
||||||
|
task = await TaskRepository(database.pool).create(
|
||||||
|
upid=upid,
|
||||||
|
task_type=f"lxc-{operation}",
|
||||||
|
payload={"node": node, "vmid": vmid, "resource_id": str(row["id"])},
|
||||||
|
resource_key=f"lxc:{vmid}",
|
||||||
|
idempotency_key=request.headers.get("Idempotency-Key"),
|
||||||
|
)
|
||||||
|
except ConflictError as error:
|
||||||
|
raise ApiError(409, str(error)) from error
|
||||||
|
return task.upid
|
||||||
|
|
||||||
|
async def start(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
return await mutate("start", request, inputs)
|
||||||
|
|
||||||
|
async def stop(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
return await mutate("stop", request, inputs)
|
||||||
|
|
||||||
|
async def shutdown(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
return await mutate("shutdown", request, inputs)
|
||||||
|
|
||||||
|
async def reboot(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
return await mutate("reboot", request, inputs)
|
||||||
|
|
||||||
|
async def suspend(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
return await mutate("suspend", request, inputs)
|
||||||
|
|
||||||
|
async def resume(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
return await mutate("resume", request, inputs)
|
||||||
|
|
||||||
|
async def create(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
values = _values(inputs)
|
||||||
|
node, vmid = str(values["node"]), int(values["vmid"])
|
||||||
|
database = _database(request)
|
||||||
|
if not await database.pool.fetchval(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)", node
|
||||||
|
):
|
||||||
|
raise ApiError(404, "node does not exist")
|
||||||
|
if await database.pool.fetchval(
|
||||||
|
"""SELECT EXISTS(SELECT 1 FROM resources
|
||||||
|
WHERE external_id=$1 AND kind IN ('qemu','lxc'))""",
|
||||||
|
str(vmid),
|
||||||
|
):
|
||||||
|
raise ApiError(409, "VMID already exists")
|
||||||
|
config = {
|
||||||
|
key: value
|
||||||
|
for key, value in values.items()
|
||||||
|
if key not in {"node", "vmid", "force", "start", "ostemplate"}
|
||||||
|
}
|
||||||
|
if "ostemplate" in values:
|
||||||
|
config["ostemplate"] = values["ostemplate"]
|
||||||
|
return await _create_task(
|
||||||
|
request,
|
||||||
|
node=node,
|
||||||
|
vmid=str(vmid),
|
||||||
|
task_type="lxc-create",
|
||||||
|
payload={
|
||||||
|
"node": node,
|
||||||
|
"vmid": vmid,
|
||||||
|
"config": config,
|
||||||
|
"start": bool(values.get("start")),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def update(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
values = _values(inputs)
|
||||||
|
node, vmid = str(values["node"]), str(values["vmid"])
|
||||||
|
database = _database(request)
|
||||||
|
row = await database.pool.fetchrow(
|
||||||
|
"""SELECT r.id, r.version, r.state, c.config FROM resources r
|
||||||
|
JOIN nodes n ON n.id=r.node_id
|
||||||
|
JOIN containers c ON c.resource_id=r.id
|
||||||
|
WHERE n.name=$1 AND r.kind='lxc' AND r.external_id=$2""",
|
||||||
|
node,
|
||||||
|
vmid,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "container does not exist")
|
||||||
|
control = {"node", "vmid", "digest", "delete", "revert", "skiplock"}
|
||||||
|
provided = frozenset(str(item) for item in inputs.get("provided", values))
|
||||||
|
changes = {
|
||||||
|
key: value for key, value in values.items() if key in provided and key not in control
|
||||||
|
}
|
||||||
|
delete = str(values.get("delete", "")) if "delete" in provided else ""
|
||||||
|
state = _state(row["state"])
|
||||||
|
config = _state(row["config"])
|
||||||
|
state.update(changes)
|
||||||
|
config.update(changes)
|
||||||
|
for key in delete.split(","):
|
||||||
|
if key:
|
||||||
|
state.pop(key, None)
|
||||||
|
config.pop(key, None)
|
||||||
|
status = await database.pool.execute(
|
||||||
|
"""UPDATE resources SET state=$3::jsonb, version=version+1,
|
||||||
|
updated_at=now() WHERE id=$1 AND version=$2""",
|
||||||
|
row["id"],
|
||||||
|
row["version"],
|
||||||
|
json.dumps(state, sort_keys=True),
|
||||||
|
)
|
||||||
|
if status != "UPDATE 1":
|
||||||
|
raise ApiError(409, "configuration changed concurrently")
|
||||||
|
await database.pool.execute(
|
||||||
|
"UPDATE containers SET config=$2::jsonb WHERE resource_id=$1",
|
||||||
|
row["id"],
|
||||||
|
json.dumps(config, sort_keys=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
values = _values(inputs)
|
||||||
|
node, vmid = str(values["node"]), str(values["vmid"])
|
||||||
|
database = _database(request)
|
||||||
|
row = await database.pool.fetchrow(
|
||||||
|
"""SELECT r.id, r.state FROM resources r JOIN nodes n ON n.id=r.node_id
|
||||||
|
WHERE n.name=$1 AND r.kind='lxc' AND r.external_id=$2""",
|
||||||
|
node,
|
||||||
|
vmid,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "container does not exist")
|
||||||
|
if str(_state(row["state"]).get("status")) != "stopped":
|
||||||
|
raise ApiError(409, "cannot delete a running container")
|
||||||
|
return await _create_task(
|
||||||
|
request,
|
||||||
|
node=node,
|
||||||
|
vmid=vmid,
|
||||||
|
task_type="lxc-delete",
|
||||||
|
payload={"node": node, "vmid": vmid, "resource_id": str(row["id"])},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def snapshot_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
values = _values(inputs)
|
||||||
|
resource = await _lxc_resource(request, str(values["node"]), str(values["vmid"]))
|
||||||
|
rows = await _database(request).pool.fetch(
|
||||||
|
"""SELECT name, parent_name, description, created_at FROM snapshots
|
||||||
|
WHERE resource_id=$1 ORDER BY created_at, name""",
|
||||||
|
resource["id"],
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": row["name"],
|
||||||
|
"parent": row["parent_name"],
|
||||||
|
"description": row["description"] or "",
|
||||||
|
"snaptime": int(row["created_at"].timestamp()),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
async def snapshot_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
values = _values(inputs)
|
||||||
|
row = await _snapshot(request, values)
|
||||||
|
state = _state(row["state"])
|
||||||
|
return {
|
||||||
|
"name": row["name"],
|
||||||
|
"parent": row["parent_name"],
|
||||||
|
"description": row["description"] or "",
|
||||||
|
"snaptime": int(row["created_at"].timestamp()),
|
||||||
|
**state,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def snapshot_config(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
row = await _snapshot(request, _values(inputs))
|
||||||
|
return {"description": row["description"] or "", **_state(row["state"])}
|
||||||
|
|
||||||
|
async def snapshot_update(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
values = _values(inputs)
|
||||||
|
row = await _snapshot(request, values)
|
||||||
|
await _database(request).pool.execute(
|
||||||
|
"UPDATE snapshots SET description=$2 WHERE id=$1",
|
||||||
|
row["id"],
|
||||||
|
str(values.get("description", "")),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def snapshot_task(operation: str, request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
values = _values(inputs)
|
||||||
|
node, vmid, snapname = (
|
||||||
|
str(values["node"]),
|
||||||
|
str(values["vmid"]),
|
||||||
|
str(values["snapname"]),
|
||||||
|
)
|
||||||
|
resource = await _lxc_resource(request, node, vmid)
|
||||||
|
if operation == "snapshot-create":
|
||||||
|
exists = await _database(request).pool.fetchval(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM snapshots WHERE resource_id=$1 AND name=$2)",
|
||||||
|
resource["id"],
|
||||||
|
snapname,
|
||||||
|
)
|
||||||
|
if exists:
|
||||||
|
raise ApiError(409, "snapshot already exists")
|
||||||
|
else:
|
||||||
|
await _snapshot(request, values)
|
||||||
|
return await _create_task(
|
||||||
|
request,
|
||||||
|
node=node,
|
||||||
|
vmid=vmid,
|
||||||
|
task_type=f"lxc-{operation}",
|
||||||
|
payload={
|
||||||
|
"node": node,
|
||||||
|
"vmid": vmid,
|
||||||
|
"resource_id": str(resource["id"]),
|
||||||
|
"snapname": snapname,
|
||||||
|
"description": str(values.get("description", "")),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def snapshot_create(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
return await snapshot_task("snapshot-create", request, inputs)
|
||||||
|
|
||||||
|
async def snapshot_delete(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
return await snapshot_task("snapshot-delete", request, inputs)
|
||||||
|
|
||||||
|
async def snapshot_rollback(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
return await snapshot_task("snapshot-rollback", request, inputs)
|
||||||
|
|
||||||
|
async def clone(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
values = _values(inputs)
|
||||||
|
node, vmid, newid = str(values["node"]), str(values["vmid"]), str(values["newid"])
|
||||||
|
source = await _lxc_resource(request, node, vmid)
|
||||||
|
if await _database(request).pool.fetchval(
|
||||||
|
"""SELECT EXISTS(SELECT 1 FROM resources
|
||||||
|
WHERE external_id=$1 AND kind IN ('qemu','lxc'))""",
|
||||||
|
newid,
|
||||||
|
):
|
||||||
|
raise ApiError(409, "VMID already exists")
|
||||||
|
target = str(values.get("target") or node)
|
||||||
|
if not await _database(request).pool.fetchval(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)", target
|
||||||
|
):
|
||||||
|
raise ApiError(404, "target node does not exist")
|
||||||
|
return await _create_task(
|
||||||
|
request,
|
||||||
|
node=target,
|
||||||
|
vmid=newid,
|
||||||
|
task_type="lxc-clone",
|
||||||
|
payload={
|
||||||
|
"source_resource_id": str(source["id"]),
|
||||||
|
"source_vmid": vmid,
|
||||||
|
"node": target,
|
||||||
|
"vmid": int(newid),
|
||||||
|
"name": values.get("hostname") or values.get("name"),
|
||||||
|
"full": bool(values.get("full", False)),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def migrate_preconditions(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
values = _values(inputs)
|
||||||
|
await _lxc_resource(request, str(values["node"]), str(values["vmid"]))
|
||||||
|
target = values.get("target")
|
||||||
|
if target in {None, ""}:
|
||||||
|
raise ApiError(400, "parameter 'target' is required")
|
||||||
|
target = str(target)
|
||||||
|
exists = await _database(request).pool.fetchval(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)", target
|
||||||
|
)
|
||||||
|
if not exists:
|
||||||
|
raise ApiError(404, "target node does not exist")
|
||||||
|
return {"local_disks": [], "local_resources": [], "running": False}
|
||||||
|
|
||||||
|
async def migrate(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
values = _values(inputs)
|
||||||
|
node, vmid = str(values["node"]), str(values["vmid"])
|
||||||
|
target = values.get("target")
|
||||||
|
if target in {None, ""}:
|
||||||
|
raise ApiError(400, "parameter 'target' is required")
|
||||||
|
target = str(target)
|
||||||
|
resource = await _lxc_resource(request, node, vmid)
|
||||||
|
if target == node:
|
||||||
|
raise ApiError(400, "target node is the same as source node")
|
||||||
|
await migrate_preconditions(request, inputs)
|
||||||
|
return await _create_task(
|
||||||
|
request,
|
||||||
|
node=node,
|
||||||
|
vmid=vmid,
|
||||||
|
task_type="lxc-migrate",
|
||||||
|
payload={
|
||||||
|
"resource_id": str(resource["id"]),
|
||||||
|
"node": node,
|
||||||
|
"target": target,
|
||||||
|
"vmid": vmid,
|
||||||
|
"online": bool(values.get("online", False)),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def remote_migrate(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
values = _values(inputs)
|
||||||
|
target_endpoint = str(values.get("target-endpoint") or values.get("target_endpoint") or "")
|
||||||
|
target = str(values.get("target") or "")
|
||||||
|
if not target_endpoint:
|
||||||
|
raise ApiError(400, "parameter target-endpoint is required")
|
||||||
|
if not target:
|
||||||
|
raise ApiError(400, "parameter target is required")
|
||||||
|
node, vmid = str(values["node"]), str(values["vmid"])
|
||||||
|
resource = await _lxc_resource(request, node, vmid)
|
||||||
|
if target == node:
|
||||||
|
raise ApiError(400, "target node is the same as source node")
|
||||||
|
if not await _database(request).pool.fetchval(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)", target
|
||||||
|
):
|
||||||
|
raise ApiError(404, "target node does not exist")
|
||||||
|
return await _create_task(
|
||||||
|
request,
|
||||||
|
node=node,
|
||||||
|
vmid=vmid,
|
||||||
|
task_type="lxc-remote-migrate",
|
||||||
|
payload={
|
||||||
|
"resource_id": str(resource["id"]),
|
||||||
|
"node": node,
|
||||||
|
"target": target,
|
||||||
|
"vmid": vmid,
|
||||||
|
"target-endpoint": target_endpoint,
|
||||||
|
"online": bool(values.get("online", False)),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def pending(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
values = _values(inputs)
|
||||||
|
resource = await _lxc_resource(request, str(values["node"]), str(values["vmid"]))
|
||||||
|
state = _state(resource["state"])
|
||||||
|
config = _state(resource["config"])
|
||||||
|
changes = cast(Mapping[str, Any], state.get("pending", {}))
|
||||||
|
return [
|
||||||
|
{"key": key, "value": str(config.get(key, "")), "pending": str(value)}
|
||||||
|
for key, value in sorted(changes.items())
|
||||||
|
]
|
||||||
|
|
||||||
|
async def lxc_feature(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = _values(inputs)
|
||||||
|
await _lxc_resource(request, str(payload["node"]), str(payload["vmid"]))
|
||||||
|
return {
|
||||||
|
"hasFeature": {
|
||||||
|
"snapshot": 1,
|
||||||
|
"clone": 1,
|
||||||
|
"copy": 1,
|
||||||
|
"template": 1,
|
||||||
|
"move_volume": 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async def lxc_resize(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = _values(inputs)
|
||||||
|
node, vmid = str(payload["node"]), str(payload["vmid"])
|
||||||
|
disk = str(payload.get("disk") or "rootfs")
|
||||||
|
resource = await _lxc_resource(request, node, vmid)
|
||||||
|
config = _state(resource["config"])
|
||||||
|
if disk not in config:
|
||||||
|
raise ApiError(400, f"disk {disk} does not exist")
|
||||||
|
try:
|
||||||
|
current = disk_size_bytes(str(config[disk]))
|
||||||
|
size = resize_size_bytes(str(payload["size"]), current)
|
||||||
|
except ValueError as error:
|
||||||
|
raise ApiError(400, str(error)) from error
|
||||||
|
config[disk] = replace_disk_size(str(config[disk]), size)
|
||||||
|
await _database(request).pool.execute(
|
||||||
|
"UPDATE containers SET config=$2::jsonb WHERE resource_id=$1",
|
||||||
|
resource["id"],
|
||||||
|
json.dumps(config, sort_keys=True),
|
||||||
|
)
|
||||||
|
await _database(request).pool.execute(
|
||||||
|
"""UPDATE resources SET state=state || $2::jsonb, version=version+1,
|
||||||
|
updated_at=now() WHERE id=$1""",
|
||||||
|
resource["id"],
|
||||||
|
json.dumps({disk: config[disk]}, sort_keys=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def lxc_template(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = _values(inputs)
|
||||||
|
node, vmid = str(payload["node"]), str(payload["vmid"])
|
||||||
|
resource = await _lxc_resource(request, node, vmid)
|
||||||
|
state = _state(resource["state"])
|
||||||
|
if state.get("status") != "stopped":
|
||||||
|
raise ApiError(409, "container must be stopped to convert to template")
|
||||||
|
await _database(request).pool.execute(
|
||||||
|
"UPDATE containers SET template=true WHERE resource_id=$1",
|
||||||
|
resource["id"],
|
||||||
|
)
|
||||||
|
state["template"] = True
|
||||||
|
await _database(request).pool.execute(
|
||||||
|
"UPDATE resources SET state=$2::jsonb WHERE id=$1",
|
||||||
|
resource["id"],
|
||||||
|
json.dumps(state, sort_keys=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def lxc_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
payload = _values(inputs)
|
||||||
|
node, vmid = str(payload["node"]), str(payload["vmid"])
|
||||||
|
await require_node(request, node)
|
||||||
|
await _lxc_resource(request, node, vmid)
|
||||||
|
return subdirs(
|
||||||
|
"clone",
|
||||||
|
"config",
|
||||||
|
"feature",
|
||||||
|
"firewall",
|
||||||
|
"migrate",
|
||||||
|
"pending",
|
||||||
|
"resize",
|
||||||
|
"snapshot",
|
||||||
|
"status",
|
||||||
|
"template",
|
||||||
|
)
|
||||||
|
|
||||||
|
registry.register("/nodes/{node}/lxc", "GET", lxc_list)
|
||||||
|
registry.register("/nodes/{node}/lxc", "POST", create)
|
||||||
|
registry.register("/nodes/{node}/lxc/{vmid}", "GET", lxc_index)
|
||||||
|
registry.register("/nodes/{node}/lxc/{vmid}", "DELETE", delete)
|
||||||
|
registry.register("/nodes/{node}/lxc/{vmid}/config", "GET", lxc_config)
|
||||||
|
registry.register("/nodes/{node}/lxc/{vmid}/config", "PUT", update)
|
||||||
|
registry.register("/nodes/{node}/lxc/{vmid}/status", "GET", lxc_status)
|
||||||
|
registry.register("/nodes/{node}/lxc/{vmid}/status/current", "GET", lxc_current)
|
||||||
|
registry.register("/nodes/{node}/lxc/{vmid}/status/start", "POST", start)
|
||||||
|
registry.register("/nodes/{node}/lxc/{vmid}/status/stop", "POST", stop)
|
||||||
|
registry.register("/nodes/{node}/lxc/{vmid}/status/shutdown", "POST", shutdown)
|
||||||
|
registry.register("/nodes/{node}/lxc/{vmid}/status/reboot", "POST", reboot)
|
||||||
|
registry.register("/nodes/{node}/lxc/{vmid}/status/suspend", "POST", suspend)
|
||||||
|
registry.register("/nodes/{node}/lxc/{vmid}/status/resume", "POST", resume)
|
||||||
|
registry.register("/nodes/{node}/lxc/{vmid}/snapshot", "GET", snapshot_list)
|
||||||
|
registry.register("/nodes/{node}/lxc/{vmid}/snapshot", "POST", snapshot_create)
|
||||||
|
registry.register("/nodes/{node}/lxc/{vmid}/snapshot/{snapname}", "GET", snapshot_get)
|
||||||
|
registry.register("/nodes/{node}/lxc/{vmid}/snapshot/{snapname}", "DELETE", snapshot_delete)
|
||||||
|
registry.register("/nodes/{node}/lxc/{vmid}/snapshot/{snapname}/config", "GET", snapshot_config)
|
||||||
|
registry.register("/nodes/{node}/lxc/{vmid}/snapshot/{snapname}/config", "PUT", snapshot_update)
|
||||||
|
registry.register(
|
||||||
|
"/nodes/{node}/lxc/{vmid}/snapshot/{snapname}/rollback", "POST", snapshot_rollback
|
||||||
|
)
|
||||||
|
registry.register("/nodes/{node}/lxc/{vmid}/clone", "POST", clone)
|
||||||
|
registry.register("/nodes/{node}/lxc/{vmid}/migrate", "GET", migrate_preconditions)
|
||||||
|
registry.register("/nodes/{node}/lxc/{vmid}/migrate", "POST", migrate)
|
||||||
|
registry.register("/nodes/{node}/lxc/{vmid}/remote_migrate", "POST", remote_migrate)
|
||||||
|
registry.register("/nodes/{node}/lxc/{vmid}/pending", "GET", pending)
|
||||||
|
registry.register("/nodes/{node}/lxc/{vmid}/feature", "GET", lxc_feature)
|
||||||
|
registry.register("/nodes/{node}/lxc/{vmid}/resize", "PUT", lxc_resize)
|
||||||
|
registry.register("/nodes/{node}/lxc/{vmid}/template", "POST", lxc_template)
|
||||||
|
from app.handlers.lxc_extra import register_lxc_extra_handlers
|
||||||
|
|
||||||
|
register_lxc_extra_handlers(registry)
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_task(
|
||||||
|
request: Request,
|
||||||
|
*,
|
||||||
|
node: str,
|
||||||
|
vmid: str,
|
||||||
|
task_type: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> str:
|
||||||
|
database = _database(request)
|
||||||
|
worker_type = {
|
||||||
|
"lxc-create": "pctcreate",
|
||||||
|
"lxc-delete": "pctdestroy",
|
||||||
|
"lxc-snapshot-create": "pctsnapshot",
|
||||||
|
"lxc-snapshot-delete": "pctdelsnapshot",
|
||||||
|
"lxc-snapshot-rollback": "pctrollback",
|
||||||
|
"lxc-clone": "pctclone",
|
||||||
|
"lxc-migrate": "pctmigrate",
|
||||||
|
"lxc-remote-migrate": "pctremote",
|
||||||
|
}[task_type]
|
||||||
|
upid = str(Upid.allocate(node, worker_type, vmid, str(request.state.principal)))
|
||||||
|
try:
|
||||||
|
task = await TaskRepository(database.pool).create(
|
||||||
|
upid=upid,
|
||||||
|
task_type=task_type,
|
||||||
|
payload=payload,
|
||||||
|
resource_key=f"lxc:{vmid}",
|
||||||
|
idempotency_key=request.headers.get("Idempotency-Key"),
|
||||||
|
)
|
||||||
|
except ConflictError as error:
|
||||||
|
raise ApiError(409, str(error)) from error
|
||||||
|
return task.upid
|
||||||
|
|
||||||
|
|
||||||
|
async def _lxc_resource(request: Request, node: str, vmid: str) -> Any:
|
||||||
|
row = await _database(request).pool.fetchrow(
|
||||||
|
"""SELECT r.id, r.state, c.config FROM resources r
|
||||||
|
JOIN nodes n ON n.id=r.node_id
|
||||||
|
JOIN containers c ON c.resource_id=r.id
|
||||||
|
WHERE n.name=$1 AND r.kind='lxc' AND r.external_id=$2""",
|
||||||
|
node,
|
||||||
|
vmid,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "container does not exist")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
async def _snapshot(request: Request, values: dict[str, Any]) -> Any:
|
||||||
|
row = await _database(request).pool.fetchrow(
|
||||||
|
"""SELECT s.* FROM snapshots s
|
||||||
|
JOIN resources r ON r.id=s.resource_id JOIN nodes n ON n.id=r.node_id
|
||||||
|
WHERE n.name=$1 AND r.kind='lxc' AND r.external_id=$2 AND s.name=$3""",
|
||||||
|
str(values["node"]),
|
||||||
|
str(values["vmid"]),
|
||||||
|
str(values["snapname"]),
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "snapshot does not exist")
|
||||||
|
return row
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""Remaining LXC console / RRD / volume helpers with durable state."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from app.api.registry import HandlerRegistry
|
||||||
|
from app.config import Settings
|
||||||
|
from app.handlers.lxc import _database, _lxc_resource, _state, _values
|
||||||
|
from app.security.auth import issue_ticket
|
||||||
|
|
||||||
|
|
||||||
|
def _settings(request: Request) -> Settings:
|
||||||
|
return cast(Settings, request.app.state.settings)
|
||||||
|
|
||||||
|
|
||||||
|
async def _save_state(request: Request, resource_id: Any, state: dict[str, Any]) -> None:
|
||||||
|
await _database(request).pool.execute(
|
||||||
|
"UPDATE resources SET state=$2::jsonb, version=version+1, updated_at=now() WHERE id=$1",
|
||||||
|
resource_id,
|
||||||
|
json.dumps(state, sort_keys=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _save_config(request: Request, resource_id: Any, config: dict[str, Any]) -> None:
|
||||||
|
await _database(request).pool.execute(
|
||||||
|
"UPDATE containers SET config=$2::jsonb WHERE resource_id=$1",
|
||||||
|
resource_id,
|
||||||
|
json.dumps(config, sort_keys=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register_lxc_extra_handlers(registry: HandlerRegistry) -> None:
|
||||||
|
async def interfaces(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
values = _values(inputs)
|
||||||
|
resource = await _lxc_resource(request, str(values["node"]), str(values["vmid"]))
|
||||||
|
state = _state(resource["state"])
|
||||||
|
ifaces = state.setdefault(
|
||||||
|
"interfaces",
|
||||||
|
[{"name": "eth0", "hwaddr": "02:00:00:00:00:11", "inet": "192.0.2.20/24"}],
|
||||||
|
)
|
||||||
|
await _save_state(request, resource["id"], state)
|
||||||
|
return list(ifaces) if isinstance(ifaces, list) else []
|
||||||
|
|
||||||
|
async def move_volume(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
values = _values(inputs)
|
||||||
|
resource = await _lxc_resource(request, str(values["node"]), str(values["vmid"]))
|
||||||
|
volume = str(values.get("volume") or values.get("disk") or "rootfs")
|
||||||
|
storage = str(values.get("storage") or "local-lvm")
|
||||||
|
config = _state(resource["config"])
|
||||||
|
current = str(config.get(volume) or "")
|
||||||
|
if current:
|
||||||
|
# rewrite storage prefix when present
|
||||||
|
rest = current.split(":", 1)[1] if ":" in current else current
|
||||||
|
config[volume] = f"{storage}:{rest}"
|
||||||
|
await _save_config(request, resource["id"], config)
|
||||||
|
state = _state(resource["state"])
|
||||||
|
moves = state.setdefault("volume_moves", [])
|
||||||
|
if not isinstance(moves, list):
|
||||||
|
moves = state["volume_moves"] = []
|
||||||
|
moves.append({"volume": volume, "storage": storage})
|
||||||
|
await _save_state(request, resource["id"], state)
|
||||||
|
return f"UPID:{values['node']}:lxc-move-volume:{values['vmid']}"
|
||||||
|
|
||||||
|
async def rrd(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
values = _values(inputs)
|
||||||
|
resource = await _lxc_resource(request, str(values["node"]), str(values["vmid"]))
|
||||||
|
state = _state(resource["state"])
|
||||||
|
rrd_state = state.setdefault("rrd", {"filename": f"pve-ct-{values['vmid']}.rrd"})
|
||||||
|
await _save_state(request, resource["id"], state)
|
||||||
|
return dict(rrd_state)
|
||||||
|
|
||||||
|
async def rrddata(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
values = _values(inputs)
|
||||||
|
resource = await _lxc_resource(request, str(values["node"]), str(values["vmid"]))
|
||||||
|
state = _state(resource["state"])
|
||||||
|
series = state.setdefault(
|
||||||
|
"rrddata",
|
||||||
|
[
|
||||||
|
{"time": 1_700_000_000, "cpu": 0.02, "mem": 64 * 1024 * 1024},
|
||||||
|
{"time": 1_700_000_060, "cpu": 0.03, "mem": 66 * 1024 * 1024},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
await _save_state(request, resource["id"], state)
|
||||||
|
return list(series)
|
||||||
|
|
||||||
|
async def _console(request: Request, inputs: dict[str, Any], kind: str) -> dict[str, Any]:
|
||||||
|
values = _values(inputs)
|
||||||
|
resource = await _lxc_resource(request, str(values["node"]), str(values["vmid"]))
|
||||||
|
key = _settings(request).ticket_signing_key.get_secret_value().encode()
|
||||||
|
ticket = issue_ticket(str(request.state.principal), key)
|
||||||
|
port = 6900 + int(values["vmid"]) % 1000
|
||||||
|
state = _state(resource["state"])
|
||||||
|
consoles = state.setdefault("consoles", {})
|
||||||
|
payload = {
|
||||||
|
"type": kind,
|
||||||
|
"port": port,
|
||||||
|
"ticket": ticket,
|
||||||
|
"upid": (
|
||||||
|
f"UPID:{values['node']}:{secrets.token_hex(4)}:"
|
||||||
|
f"{kind}:{values['vmid']}:{request.state.principal}:"
|
||||||
|
),
|
||||||
|
"user": str(request.state.principal),
|
||||||
|
}
|
||||||
|
consoles[kind] = {k: v for k, v in payload.items() if k != "ticket"}
|
||||||
|
await _save_state(request, resource["id"], state)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
async def vncproxy(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await _console(request, inputs, "vnc")
|
||||||
|
|
||||||
|
async def spiceproxy(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await _console(request, inputs, "spice")
|
||||||
|
|
||||||
|
async def termproxy(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await _console(request, inputs, "term")
|
||||||
|
|
||||||
|
async def mtunnel(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await _console(request, inputs, "mtunnel")
|
||||||
|
|
||||||
|
async def _ws(request: Request, inputs: dict[str, Any], kind: str) -> dict[str, Any]:
|
||||||
|
values = _values(inputs)
|
||||||
|
resource = await _lxc_resource(request, str(values["node"]), str(values["vmid"]))
|
||||||
|
state = _state(resource["state"])
|
||||||
|
console = state.get("consoles", {}).get(kind) or {"port": 6900}
|
||||||
|
key = _settings(request).ticket_signing_key.get_secret_value().encode()
|
||||||
|
return {
|
||||||
|
"port": console.get("port", 6900),
|
||||||
|
"ticket": issue_ticket(str(request.state.principal), key),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def vncwebsocket(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await _ws(request, inputs, "vnc")
|
||||||
|
|
||||||
|
async def mtunnelwebsocket(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await _ws(request, inputs, "mtunnel")
|
||||||
|
|
||||||
|
base = "/nodes/{node}/lxc/{vmid}"
|
||||||
|
registry.register(f"{base}/interfaces", "GET", interfaces)
|
||||||
|
registry.register(f"{base}/move_volume", "POST", move_volume)
|
||||||
|
registry.register(f"{base}/rrd", "GET", rrd)
|
||||||
|
registry.register(f"{base}/rrddata", "GET", rrddata)
|
||||||
|
registry.register(f"{base}/vncproxy", "POST", vncproxy)
|
||||||
|
registry.register(f"{base}/spiceproxy", "POST", spiceproxy)
|
||||||
|
registry.register(f"{base}/termproxy", "POST", termproxy)
|
||||||
|
registry.register(f"{base}/mtunnel", "POST", mtunnel)
|
||||||
|
registry.register(f"{base}/vncwebsocket", "GET", vncwebsocket)
|
||||||
|
registry.register(f"{base}/mtunnelwebsocket", "GET", mtunnelwebsocket)
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Cluster resource mapping handlers (dir/pci/usb)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from app.api.errors import ApiError
|
||||||
|
from app.api.registry import HandlerRegistry
|
||||||
|
from app.handlers.common import cluster_metadata, save_cluster_metadata, subdirs, values
|
||||||
|
|
||||||
|
|
||||||
|
def _mappings(metadata: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
current = metadata.setdefault("mapping", {"dir": {}, "pci": {}, "usb": {}})
|
||||||
|
if not isinstance(current, dict):
|
||||||
|
current = {"dir": {}, "pci": {}, "usb": {}}
|
||||||
|
metadata["mapping"] = current
|
||||||
|
for kind in ("dir", "pci", "usb"):
|
||||||
|
current.setdefault(kind, {})
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
def register_mapping_handlers(registry: HandlerRegistry) -> None:
|
||||||
|
async def index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
return subdirs("dir", "pci", "usb")
|
||||||
|
|
||||||
|
def register_kind(kind: str) -> None:
|
||||||
|
base = f"/cluster/mapping/{kind}"
|
||||||
|
|
||||||
|
async def list_items(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
store = _mappings(metadata)[kind]
|
||||||
|
check_node = values(inputs).get("check-node")
|
||||||
|
result = [{"id": key, **item} for key, item in sorted(store.items())]
|
||||||
|
if check_node:
|
||||||
|
for item in result:
|
||||||
|
item["checks"] = {str(check_node): "OK"}
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def create(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
item_id = str(payload["id"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
store = _mappings(metadata)[kind]
|
||||||
|
if item_id in store:
|
||||||
|
raise ApiError(400, f"{kind} mapping '{item_id}' already exists")
|
||||||
|
store[item_id] = {
|
||||||
|
key: value for key, value in payload.items() if key not in {"delete", "digest"}
|
||||||
|
}
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
|
||||||
|
async def get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
item_id = str(values(inputs)["id"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
store = _mappings(metadata)[kind]
|
||||||
|
if item_id not in store:
|
||||||
|
raise ApiError(404, f"{kind} mapping does not exist")
|
||||||
|
return {"id": item_id, **store[item_id]}
|
||||||
|
|
||||||
|
async def update(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
item_id = str(payload["id"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
store = _mappings(metadata)[kind]
|
||||||
|
if item_id not in store:
|
||||||
|
raise ApiError(404, f"{kind} mapping does not exist")
|
||||||
|
current = dict(store[item_id])
|
||||||
|
for key in [
|
||||||
|
item.strip() for item in str(payload.get("delete") or "").split(",") if item.strip()
|
||||||
|
]:
|
||||||
|
current.pop(key, None)
|
||||||
|
for key, value in payload.items():
|
||||||
|
if key in {"id", "delete", "digest"}:
|
||||||
|
continue
|
||||||
|
current[key] = value
|
||||||
|
current["id"] = item_id
|
||||||
|
store[item_id] = current
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
|
||||||
|
async def delete(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
item_id = str(values(inputs)["id"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
store = _mappings(metadata)[kind]
|
||||||
|
if item_id not in store:
|
||||||
|
raise ApiError(404, f"{kind} mapping does not exist")
|
||||||
|
del store[item_id]
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
|
||||||
|
registry.register(base, "GET", list_items)
|
||||||
|
registry.register(base, "POST", create)
|
||||||
|
registry.register(f"{base}/{{id}}", "GET", get)
|
||||||
|
registry.register(f"{base}/{{id}}", "PUT", update)
|
||||||
|
registry.register(f"{base}/{{id}}", "DELETE", delete)
|
||||||
|
|
||||||
|
registry.register("/cluster/mapping", "GET", index)
|
||||||
|
register_kind("dir")
|
||||||
|
register_kind("pci")
|
||||||
|
register_kind("usb")
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
"""Node-level operational handlers (apt, network, disks, services)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from app.api.errors import ApiError
|
||||||
|
from app.api.registry import HandlerRegistry
|
||||||
|
from app.handlers.common import (
|
||||||
|
database,
|
||||||
|
node_metadata,
|
||||||
|
require_node,
|
||||||
|
save_node_metadata,
|
||||||
|
subdirs,
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
from app.tasks.repository import TaskRepository
|
||||||
|
from app.tasks.upid import Upid
|
||||||
|
|
||||||
|
DEFAULT_NODE_OPS: dict[str, Any] = {
|
||||||
|
"network": [
|
||||||
|
{
|
||||||
|
"iface": "vmbr0",
|
||||||
|
"type": "bridge",
|
||||||
|
"active": 1,
|
||||||
|
"method": "static",
|
||||||
|
"address": "10.0.0.10/24",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"iface": "vmbr1",
|
||||||
|
"type": "bridge",
|
||||||
|
"active": 1,
|
||||||
|
"method": "static",
|
||||||
|
"address": "10.10.0.10/24",
|
||||||
|
},
|
||||||
|
{"iface": "eno1", "type": "eth", "active": 1, "method": "manual"},
|
||||||
|
],
|
||||||
|
"disks": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"devpath": "/dev/sda",
|
||||||
|
"size": 1_000_000_000_000,
|
||||||
|
"model": "SIM-DISK-01",
|
||||||
|
"serial": "SIM0001",
|
||||||
|
"gpt": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"devpath": "/dev/sdb",
|
||||||
|
"size": 2_000_000_000_000,
|
||||||
|
"model": "SIM-SSD-01",
|
||||||
|
"serial": "SIM0002",
|
||||||
|
"gpt": 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"directory": [],
|
||||||
|
"lvm": [],
|
||||||
|
"lvmthin": [],
|
||||||
|
"zfs": [],
|
||||||
|
"smart": {},
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"pveproxy": {"state": "running", "enabled": 1},
|
||||||
|
"pvedaemon": {"state": "running", "enabled": 1},
|
||||||
|
"pvestatd": {"state": "running", "enabled": 1},
|
||||||
|
"corosync": {"state": "running", "enabled": 1},
|
||||||
|
},
|
||||||
|
"apt": {
|
||||||
|
"packages": [
|
||||||
|
{
|
||||||
|
"Package": "pve-manager",
|
||||||
|
"Version": "9.2.3",
|
||||||
|
"OldVersion": "9.2.2",
|
||||||
|
"Status": "upgradable",
|
||||||
|
},
|
||||||
|
{"Package": "libpve-common-perl", "Version": "9.0.3", "Status": "installed"},
|
||||||
|
],
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"path": "/etc/apt/sources.list.d/pve-enterprise.list",
|
||||||
|
"enabled": 1,
|
||||||
|
"types": "deb",
|
||||||
|
"uri": "http://download.proxmox.com/debian/pve",
|
||||||
|
"suites": "bookworm",
|
||||||
|
"components": "pve-no-subscription",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"update": {"status": "stopped", "exitstatus": "OK"},
|
||||||
|
"changelogs": {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def default_node_ops() -> dict[str, Any]:
|
||||||
|
return copy.deepcopy(DEFAULT_NODE_OPS)
|
||||||
|
|
||||||
|
|
||||||
|
async def load_node_ops(request: Request, node: str) -> dict[str, Any]:
|
||||||
|
metadata = await node_metadata(request, node)
|
||||||
|
ops = metadata.get("ops")
|
||||||
|
if isinstance(ops, dict) and ops:
|
||||||
|
return ops
|
||||||
|
ops = default_node_ops()
|
||||||
|
metadata["ops"] = ops
|
||||||
|
await save_node_metadata(request, node, metadata)
|
||||||
|
return ops
|
||||||
|
|
||||||
|
|
||||||
|
async def save_node_ops(request: Request, node: str, ops: dict[str, Any]) -> None:
|
||||||
|
metadata = await node_metadata(request, node)
|
||||||
|
metadata["ops"] = ops
|
||||||
|
await save_node_metadata(request, node, metadata)
|
||||||
|
|
||||||
|
|
||||||
|
def register_node_ops_handlers(registry: HandlerRegistry) -> None:
|
||||||
|
async def apt_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
await require_node(request, str(values(inputs)["node"]))
|
||||||
|
return subdirs("changelog", "repositories", "update", "versions")
|
||||||
|
|
||||||
|
async def apt_versions(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
packages = ops.get("apt", {}).get("packages", [])
|
||||||
|
return list(packages) if isinstance(packages, list) else []
|
||||||
|
|
||||||
|
async def apt_repositories(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
repositories = ops.get("apt", {}).get("repositories", [])
|
||||||
|
return list(repositories) if isinstance(repositories, list) else []
|
||||||
|
|
||||||
|
async def apt_changelog(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
name = str(values(inputs).get("name") or "pve-manager")
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
changelogs = ops.setdefault("apt", {}).setdefault("changelogs", {})
|
||||||
|
if name not in changelogs:
|
||||||
|
changelogs[name] = f"simulated changelog for {name}\n\n * emulator build\n"
|
||||||
|
await save_node_ops(request, node, ops)
|
||||||
|
return str(changelogs[name])
|
||||||
|
|
||||||
|
async def apt_update_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
update = ops.get("apt", {}).get("update", {"status": "stopped", "exitstatus": "OK"})
|
||||||
|
if isinstance(update, dict):
|
||||||
|
return dict(update)
|
||||||
|
return {"status": "stopped", "exitstatus": "OK"}
|
||||||
|
|
||||||
|
async def apt_update_start(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
apt = ops.setdefault("apt", {})
|
||||||
|
apt["update"] = {"status": "running", "exitstatus": ""}
|
||||||
|
await save_node_ops(request, node, ops)
|
||||||
|
return await _node_task(request, node=node, task_type="aptupdate", worker="aptupdate")
|
||||||
|
|
||||||
|
async def network_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
network = ops.get("network", [])
|
||||||
|
return [dict(item) for item in network] if isinstance(network, list) else []
|
||||||
|
|
||||||
|
async def network_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
iface = str(values(inputs)["iface"])
|
||||||
|
for item in await network_list(request, inputs):
|
||||||
|
if item.get("iface") == iface:
|
||||||
|
return item
|
||||||
|
raise ApiError(404, "interface does not exist")
|
||||||
|
|
||||||
|
async def network_mutate(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
payload = values(inputs)
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
network = list(ops.get("network") or [])
|
||||||
|
iface = payload.get("iface")
|
||||||
|
method = request.method.upper()
|
||||||
|
if method == "DELETE":
|
||||||
|
target = str(iface or "")
|
||||||
|
if not any(item.get("iface") == target for item in network):
|
||||||
|
raise ApiError(404, "interface does not exist")
|
||||||
|
ops["network"] = [item for item in network if item.get("iface") != target]
|
||||||
|
elif method == "POST":
|
||||||
|
name = str(iface or payload.get("iface") or "")
|
||||||
|
if not name:
|
||||||
|
raise ApiError(400, "parameter verification failed - 'iface' missing")
|
||||||
|
if any(item.get("iface") == name for item in network):
|
||||||
|
raise ApiError(400, f"interface '{name}' already exists")
|
||||||
|
entry = {
|
||||||
|
key: value
|
||||||
|
for key, value in payload.items()
|
||||||
|
if key not in {"node", "delete", "digest"}
|
||||||
|
}
|
||||||
|
entry["iface"] = name
|
||||||
|
entry.setdefault("type", "bridge")
|
||||||
|
entry.setdefault("active", 1)
|
||||||
|
network.append(entry)
|
||||||
|
ops["network"] = network
|
||||||
|
elif method == "PUT" and iface is not None:
|
||||||
|
name = str(iface)
|
||||||
|
found = False
|
||||||
|
updated: list[dict[str, Any]] = []
|
||||||
|
for item in network:
|
||||||
|
if item.get("iface") != name:
|
||||||
|
updated.append(item)
|
||||||
|
continue
|
||||||
|
found = True
|
||||||
|
merged = {
|
||||||
|
**item,
|
||||||
|
**{
|
||||||
|
key: value
|
||||||
|
for key, value in payload.items()
|
||||||
|
if key not in {"node", "iface", "delete", "digest"}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
merged["iface"] = name
|
||||||
|
updated.append(merged)
|
||||||
|
if not found:
|
||||||
|
raise ApiError(404, "interface does not exist")
|
||||||
|
ops["network"] = updated
|
||||||
|
else:
|
||||||
|
ops["network_applied"] = True
|
||||||
|
await save_node_ops(request, node, ops)
|
||||||
|
|
||||||
|
async def disks_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
await require_node(request, str(values(inputs)["node"]))
|
||||||
|
return subdirs("directory", "list", "lvm", "lvmthin", "smart", "zfs")
|
||||||
|
|
||||||
|
async def _disks(request: Request, node: str) -> dict[str, Any]:
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
disks = ops.setdefault("disks", default_node_ops()["disks"])
|
||||||
|
if not isinstance(disks, dict):
|
||||||
|
disks = default_node_ops()["disks"]
|
||||||
|
ops["disks"] = disks
|
||||||
|
await save_node_ops(request, node, ops)
|
||||||
|
return cast(dict[str, Any], disks)
|
||||||
|
|
||||||
|
async def disks_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
disks = await _disks(request, node)
|
||||||
|
items = disks.get("list", [])
|
||||||
|
return [dict(item) for item in items] if isinstance(items, list) else []
|
||||||
|
|
||||||
|
async def disks_smart(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
disk = str(values(inputs).get("disk") or "/dev/sda")
|
||||||
|
disks = await _disks(request, node)
|
||||||
|
smart = disks.setdefault("smart", {})
|
||||||
|
if disk not in smart:
|
||||||
|
smart[disk] = {
|
||||||
|
"health": "PASSED",
|
||||||
|
"type": "scsi",
|
||||||
|
"model": "SIM-DISK",
|
||||||
|
"serial": disk.rsplit("/", 1)[-1],
|
||||||
|
}
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
ops["disks"] = disks
|
||||||
|
await save_node_ops(request, node, ops)
|
||||||
|
return dict(smart[disk])
|
||||||
|
|
||||||
|
async def disks_collection(
|
||||||
|
request: Request, inputs: dict[str, Any], key: str
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
disks = await _disks(request, node)
|
||||||
|
items = disks.get(key, [])
|
||||||
|
return [dict(item) for item in items] if isinstance(items, list) else []
|
||||||
|
|
||||||
|
async def disks_directory(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
return await disks_collection(request, inputs, "directory")
|
||||||
|
|
||||||
|
async def disks_lvm(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
return await disks_collection(request, inputs, "lvm")
|
||||||
|
|
||||||
|
async def disks_lvmthin(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
return await disks_collection(request, inputs, "lvmthin")
|
||||||
|
|
||||||
|
async def disks_zfs(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
return await disks_collection(request, inputs, "zfs")
|
||||||
|
|
||||||
|
async def disks_initgpt(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
disk = str(values(inputs).get("disk") or values(inputs).get("device") or "")
|
||||||
|
if not disk:
|
||||||
|
raise ApiError(400, "parameter verification failed - 'disk' missing")
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
disks = ops.setdefault("disks", default_node_ops()["disks"])
|
||||||
|
items = list(disks.get("list") or [])
|
||||||
|
found = False
|
||||||
|
for item in items:
|
||||||
|
if item.get("devpath") == disk:
|
||||||
|
item["gpt"] = 1
|
||||||
|
found = True
|
||||||
|
break
|
||||||
|
if not found:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"devpath": disk,
|
||||||
|
"size": 0,
|
||||||
|
"model": "SIM-DISK",
|
||||||
|
"serial": disk,
|
||||||
|
"gpt": 1,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
disks["list"] = items
|
||||||
|
ops["disks"] = disks
|
||||||
|
await save_node_ops(request, node, ops)
|
||||||
|
|
||||||
|
async def disks_wipedisk(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
disk = str(values(inputs).get("disk") or values(inputs).get("device") or "")
|
||||||
|
if not disk:
|
||||||
|
raise ApiError(400, "parameter verification failed - 'disk' missing")
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
disks = ops.setdefault("disks", default_node_ops()["disks"])
|
||||||
|
items = list(disks.get("list") or [])
|
||||||
|
for item in items:
|
||||||
|
if item.get("devpath") == disk:
|
||||||
|
item["wiped"] = 1
|
||||||
|
item["gpt"] = 0
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise ApiError(404, "disk does not exist")
|
||||||
|
disks["list"] = items
|
||||||
|
smart = disks.setdefault("smart", {})
|
||||||
|
smart.pop(disk, None)
|
||||||
|
ops["disks"] = disks
|
||||||
|
await save_node_ops(request, node, ops)
|
||||||
|
|
||||||
|
async def services_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
services = ops.get("services") or {}
|
||||||
|
return [{"subdir": name} for name in sorted(services)]
|
||||||
|
|
||||||
|
async def service_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
service = str(values(inputs)["service"])
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
services = ops.setdefault("services", {})
|
||||||
|
if service not in services:
|
||||||
|
services[service] = {"state": "stopped", "enabled": 0}
|
||||||
|
await save_node_ops(request, node, ops)
|
||||||
|
payload = dict(services[service])
|
||||||
|
payload["service"] = service
|
||||||
|
return payload
|
||||||
|
|
||||||
|
async def service_state(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await service_get(request, inputs)
|
||||||
|
|
||||||
|
async def service_action(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
service = str(values(inputs)["service"])
|
||||||
|
path = request.url.path.rstrip("/")
|
||||||
|
action = path.rsplit("/", 1)[-1]
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
services = ops.setdefault("services", {})
|
||||||
|
current = dict(services.get(service) or {"state": "stopped", "enabled": 0})
|
||||||
|
if action == "start":
|
||||||
|
current["state"] = "running"
|
||||||
|
current["enabled"] = 1
|
||||||
|
elif action == "stop":
|
||||||
|
current["state"] = "stopped"
|
||||||
|
elif action in {"restart", "reload"}:
|
||||||
|
current["state"] = "running"
|
||||||
|
current["enabled"] = 1
|
||||||
|
else:
|
||||||
|
raise ApiError(400, f"unknown service action: {action}")
|
||||||
|
services[service] = current
|
||||||
|
ops["services"] = services
|
||||||
|
await save_node_ops(request, node, ops)
|
||||||
|
return "OK"
|
||||||
|
|
||||||
|
registry.register("/nodes/{node}/apt", "GET", apt_index)
|
||||||
|
registry.register("/nodes/{node}/apt/versions", "GET", apt_versions)
|
||||||
|
registry.register("/nodes/{node}/apt/repositories", "GET", apt_repositories)
|
||||||
|
registry.register("/nodes/{node}/apt/changelog", "GET", apt_changelog)
|
||||||
|
registry.register("/nodes/{node}/apt/update", "GET", apt_update_status)
|
||||||
|
registry.register("/nodes/{node}/apt/update", "POST", apt_update_start)
|
||||||
|
registry.register("/nodes/{node}/network", "GET", network_list)
|
||||||
|
registry.register("/nodes/{node}/network", "POST", network_mutate)
|
||||||
|
registry.register("/nodes/{node}/network", "PUT", network_mutate)
|
||||||
|
registry.register("/nodes/{node}/network/{iface}", "GET", network_get)
|
||||||
|
registry.register("/nodes/{node}/network/{iface}", "PUT", network_mutate)
|
||||||
|
registry.register("/nodes/{node}/network/{iface}", "DELETE", network_mutate)
|
||||||
|
registry.register("/nodes/{node}/disks", "GET", disks_index)
|
||||||
|
registry.register("/nodes/{node}/disks/list", "GET", disks_list)
|
||||||
|
registry.register("/nodes/{node}/disks/smart", "GET", disks_smart)
|
||||||
|
registry.register("/nodes/{node}/disks/directory", "GET", disks_directory)
|
||||||
|
registry.register("/nodes/{node}/disks/lvm", "GET", disks_lvm)
|
||||||
|
registry.register("/nodes/{node}/disks/lvmthin", "GET", disks_lvmthin)
|
||||||
|
registry.register("/nodes/{node}/disks/zfs", "GET", disks_zfs)
|
||||||
|
registry.register("/nodes/{node}/disks/initgpt", "POST", disks_initgpt)
|
||||||
|
registry.register("/nodes/{node}/disks/wipedisk", "PUT", disks_wipedisk)
|
||||||
|
registry.register("/nodes/{node}/services", "GET", services_index)
|
||||||
|
registry.register("/nodes/{node}/services/{service}", "GET", service_get)
|
||||||
|
registry.register("/nodes/{node}/services/{service}/state", "GET", service_state)
|
||||||
|
registry.register("/nodes/{node}/services/{service}/start", "POST", service_action)
|
||||||
|
registry.register("/nodes/{node}/services/{service}/stop", "POST", service_action)
|
||||||
|
registry.register("/nodes/{node}/services/{service}/restart", "POST", service_action)
|
||||||
|
registry.register("/nodes/{node}/services/{service}/reload", "POST", service_action)
|
||||||
|
|
||||||
|
|
||||||
|
async def _node_task(request: Request, *, node: str, task_type: str, worker: str) -> str:
|
||||||
|
from app.api.errors import ApiError
|
||||||
|
from app.db.primitives import ConflictError
|
||||||
|
|
||||||
|
pool = database(request).pool
|
||||||
|
upid = str(Upid.allocate(node, worker, "0", str(request.state.principal)))
|
||||||
|
try:
|
||||||
|
task = await TaskRepository(pool).create(
|
||||||
|
upid=upid,
|
||||||
|
task_type=task_type,
|
||||||
|
payload={"node": node},
|
||||||
|
resource_key=f"node:{node}",
|
||||||
|
)
|
||||||
|
except ConflictError as error:
|
||||||
|
raise ApiError(409, str(error)) from error
|
||||||
|
return task.upid
|
||||||
@@ -0,0 +1,972 @@
|
|||||||
|
"""Additional node-level handlers with durable ops persistence."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from app.api.errors import ApiError
|
||||||
|
from app.api.registry import HandlerRegistry
|
||||||
|
from app.handlers.common import database, require_node, subdirs, values
|
||||||
|
from app.handlers.nodes import default_node_ops, load_node_ops, save_node_ops
|
||||||
|
from app.tasks.repository import TaskRepository
|
||||||
|
from app.tasks.upid import Upid
|
||||||
|
|
||||||
|
DEFAULT_HARDWARE: dict[str, Any] = {
|
||||||
|
"pci": [
|
||||||
|
{
|
||||||
|
"id": "0000:00:1f.2",
|
||||||
|
"vendor_name": "Intel Corporation",
|
||||||
|
"device_name": "SATA Controller",
|
||||||
|
"iommugroup": 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "0000:01:00.0",
|
||||||
|
"vendor_name": "NVIDIA Corporation",
|
||||||
|
"device_name": "GP102 [GeForce GTX 1080 Ti]",
|
||||||
|
"iommugroup": 1,
|
||||||
|
"mdev": 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"usb": [
|
||||||
|
{"busnum": 1, "devnum": 1, "level": 0, "port": "1", "prodid": "0002", "vendid": "1d6b"},
|
||||||
|
{"busnum": 2, "devnum": 2, "level": 1, "port": "2", "prodid": "5591", "vendid": "0781"},
|
||||||
|
],
|
||||||
|
"mdev": {
|
||||||
|
"0000:01:00.0": [
|
||||||
|
{"type": "nvidia-11", "available": 4, "description": "GRID profile"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_SCAN: dict[str, list[dict[str, Any]]] = {
|
||||||
|
"cifs": [{"server": "files.local", "share": "backups"}],
|
||||||
|
"iscsi": [{"portal": "10.0.0.50:3260", "target": "iqn.2024-01.local:storage"}],
|
||||||
|
"lvm": [{"vg": "pve", "size": 500_000_000_000, "free": 100_000_000_000}],
|
||||||
|
"lvmthin": [{"lv": "data", "vg": "pve", "lv_size": 400_000_000_000}],
|
||||||
|
"nfs": [{"server": "nfs.local", "path": "/export/pve", "options": "vers=4"}],
|
||||||
|
"pbs": [{"server": "pbs.local", "datastore": "store1"}],
|
||||||
|
"zfs": [{"pool": "rpool", "name": "rpool/data", "size": 800_000_000_000}],
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_SUBSCRIPTION: dict[str, Any] = {
|
||||||
|
"status": "notfound",
|
||||||
|
"message": "There is no subscription key",
|
||||||
|
"serverid": "SIMULATOR",
|
||||||
|
"sockets": 1,
|
||||||
|
"productname": "Proxmox VE",
|
||||||
|
"url": "https://www.proxmox.com/en/proxmox-virtual-environment/pricing",
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_CONFIG: dict[str, Any] = {
|
||||||
|
"description": "Simulator node",
|
||||||
|
"startall-onboot-delay": 0,
|
||||||
|
"wakeonlan": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_DNS: dict[str, Any] = {
|
||||||
|
"search": "local",
|
||||||
|
"dns1": "1.1.1.1",
|
||||||
|
"dns2": "8.8.8.8",
|
||||||
|
"dns3": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_TIME: dict[str, Any] = {
|
||||||
|
"timezone": "UTC",
|
||||||
|
"time": 0,
|
||||||
|
"localtime": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _certificates(ops: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
certs = ops.setdefault(
|
||||||
|
"certificates",
|
||||||
|
{
|
||||||
|
"custom": None,
|
||||||
|
"acme": {"account": "default", "domains": [], "certificate": None},
|
||||||
|
"info": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not isinstance(certs, dict):
|
||||||
|
certs = {"custom": None, "acme": {}, "info": []}
|
||||||
|
ops["certificates"] = certs
|
||||||
|
certs.setdefault("acme", {"account": "default", "domains": [], "certificate": None})
|
||||||
|
certs.setdefault("info", [])
|
||||||
|
return certs
|
||||||
|
|
||||||
|
|
||||||
|
def _hardware(ops: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
hardware = ops.get("hardware")
|
||||||
|
if not isinstance(hardware, dict) or not hardware:
|
||||||
|
hardware = copy.deepcopy(DEFAULT_HARDWARE)
|
||||||
|
ops["hardware"] = hardware
|
||||||
|
hardware.setdefault("pci", copy.deepcopy(DEFAULT_HARDWARE["pci"]))
|
||||||
|
hardware.setdefault("usb", copy.deepcopy(DEFAULT_HARDWARE["usb"]))
|
||||||
|
hardware.setdefault("mdev", copy.deepcopy(DEFAULT_HARDWARE["mdev"]))
|
||||||
|
return hardware
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_cache(ops: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
scan = ops.get("scan")
|
||||||
|
if not isinstance(scan, dict) or not scan:
|
||||||
|
scan = copy.deepcopy(DEFAULT_SCAN)
|
||||||
|
ops["scan"] = scan
|
||||||
|
for key, value in DEFAULT_SCAN.items():
|
||||||
|
scan.setdefault(key, copy.deepcopy(value))
|
||||||
|
return scan
|
||||||
|
|
||||||
|
|
||||||
|
def _subscription(ops: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
subscription = ops.get("subscription")
|
||||||
|
if not isinstance(subscription, dict) or not subscription:
|
||||||
|
subscription = copy.deepcopy(DEFAULT_SUBSCRIPTION)
|
||||||
|
ops["subscription"] = subscription
|
||||||
|
return subscription
|
||||||
|
|
||||||
|
|
||||||
|
def _disk_items(ops: dict[str, Any], kind: str) -> list[dict[str, Any]]:
|
||||||
|
disks = ops.setdefault("disks", default_node_ops()["disks"])
|
||||||
|
if not isinstance(disks, dict):
|
||||||
|
disks = default_node_ops()["disks"]
|
||||||
|
ops["disks"] = disks
|
||||||
|
items = disks.setdefault(kind, [])
|
||||||
|
if not isinstance(items, list):
|
||||||
|
items = []
|
||||||
|
disks[kind] = items
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def _public_cert(entry: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
return {key: value for key, value in entry.items() if key not in {"key", "private-key"}}
|
||||||
|
|
||||||
|
|
||||||
|
async def _node_task(request: Request, *, node: str, task_type: str, worker: str) -> str:
|
||||||
|
from app.db.primitives import ConflictError
|
||||||
|
|
||||||
|
pool = database(request).pool
|
||||||
|
upid = str(Upid.allocate(node, worker, "0", str(request.state.principal)))
|
||||||
|
try:
|
||||||
|
task = await TaskRepository(pool).create(
|
||||||
|
upid=upid,
|
||||||
|
task_type=task_type,
|
||||||
|
payload={"node": node},
|
||||||
|
resource_key=f"node:{node}:{task_type}",
|
||||||
|
)
|
||||||
|
except ConflictError as error:
|
||||||
|
raise ApiError(409, str(error)) from error
|
||||||
|
return task.upid
|
||||||
|
|
||||||
|
|
||||||
|
async def _set_guest_status(request: Request, node: str, status: str) -> None:
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"""UPDATE resources AS r
|
||||||
|
SET state = jsonb_set(COALESCE(r.state, '{}'::jsonb), '{status}', to_jsonb($2::text), true),
|
||||||
|
updated_at=now()
|
||||||
|
WHERE r.node_id=(SELECT id FROM nodes WHERE name=$1) AND r.kind IN ('qemu', 'lxc')""",
|
||||||
|
node,
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _migrate_guests(request: Request, node: str, target: str) -> None:
|
||||||
|
target_row = await database(request).pool.fetchrow("SELECT id FROM nodes WHERE name=$1", target)
|
||||||
|
if target_row is None:
|
||||||
|
raise ApiError(404, "target node does not exist")
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"""UPDATE resources SET node_id=$2, updated_at=now()
|
||||||
|
WHERE node_id=(SELECT id FROM nodes WHERE name=$1) AND kind IN ('qemu', 'lxc')""",
|
||||||
|
node,
|
||||||
|
target_row["id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register_nodes_extra_handlers(registry: HandlerRegistry) -> None:
|
||||||
|
async def disks_create(request: Request, inputs: dict[str, Any], kind: str) -> dict[str, Any]:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
name = str(
|
||||||
|
payload.get("name")
|
||||||
|
or payload.get("device")
|
||||||
|
or payload.get("vgname")
|
||||||
|
or payload.get("pool")
|
||||||
|
or f"{kind}-{secrets.token_hex(2)}"
|
||||||
|
)
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
items = _disk_items(ops, kind)
|
||||||
|
if any(str(item.get("name")) == name for item in items):
|
||||||
|
raise ApiError(400, f"{kind} '{name}' already exists")
|
||||||
|
entry = {
|
||||||
|
key: value for key, value in payload.items() if key not in {"node", "delete", "digest"}
|
||||||
|
}
|
||||||
|
entry["name"] = name
|
||||||
|
items.append(entry)
|
||||||
|
ops.setdefault("disks", default_node_ops()["disks"])[kind] = items
|
||||||
|
await save_node_ops(request, node, ops)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
async def disks_delete(request: Request, inputs: dict[str, Any], kind: str) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
name = str(payload["name"])
|
||||||
|
await require_node(request, node)
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
items = _disk_items(ops, kind)
|
||||||
|
remaining = [item for item in items if str(item.get("name")) != name]
|
||||||
|
if len(remaining) == len(items):
|
||||||
|
raise ApiError(404, f"{kind} does not exist")
|
||||||
|
ops.setdefault("disks", {})[kind] = remaining
|
||||||
|
await save_node_ops(request, node, ops)
|
||||||
|
|
||||||
|
async def disks_zfs_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
name = str(payload["name"])
|
||||||
|
await require_node(request, node)
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
for item in _disk_items(ops, "zfs"):
|
||||||
|
if str(item.get("name")) == name:
|
||||||
|
return dict(item)
|
||||||
|
raise ApiError(404, "zfs pool does not exist")
|
||||||
|
|
||||||
|
async def certificates_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
await require_node(request, str(values(inputs)["node"]))
|
||||||
|
return subdirs("acme", "custom", "info")
|
||||||
|
|
||||||
|
async def certificates_acme_index(
|
||||||
|
request: Request, inputs: dict[str, Any]
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
await require_node(request, str(values(inputs)["node"]))
|
||||||
|
return subdirs("certificate")
|
||||||
|
|
||||||
|
async def certificates_acme_mutate(request: Request, inputs: dict[str, Any]) -> str | None:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
certs = _certificates(ops)
|
||||||
|
acme = dict(certs.get("acme") or {})
|
||||||
|
method = request.method.upper()
|
||||||
|
if method == "DELETE":
|
||||||
|
acme["certificate"] = None
|
||||||
|
acme["domains"] = []
|
||||||
|
else:
|
||||||
|
domains = payload.get("domains") or payload.get("domain") or acme.get("domains") or []
|
||||||
|
if isinstance(domains, str):
|
||||||
|
domains = [part.strip() for part in domains.split(",") if part.strip()]
|
||||||
|
acme["domains"] = list(domains)
|
||||||
|
acme["account"] = str(payload.get("account") or acme.get("account") or "default")
|
||||||
|
acme["certificate"] = {
|
||||||
|
"pem": str(payload.get("certificates") or payload.get("certificate") or "SIM-ACME"),
|
||||||
|
"issued": int(time.time()),
|
||||||
|
}
|
||||||
|
certs["acme"] = acme
|
||||||
|
ops["certificates"] = certs
|
||||||
|
await save_node_ops(request, node, ops)
|
||||||
|
if method == "DELETE":
|
||||||
|
return None
|
||||||
|
return await _node_task(request, node=node, task_type="acme", worker="acme")
|
||||||
|
|
||||||
|
async def certificates_custom(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
certs = _certificates(ops)
|
||||||
|
if request.method.upper() == "DELETE":
|
||||||
|
certs["custom"] = None
|
||||||
|
else:
|
||||||
|
certificates = str(payload.get("certificates") or payload.get("cert") or "")
|
||||||
|
if not certificates:
|
||||||
|
raise ApiError(400, "parameter verification failed - 'certificates' missing")
|
||||||
|
key = str(payload.get("key") or payload.get("private-key") or "")
|
||||||
|
certs["custom"] = {
|
||||||
|
"certificates": certificates,
|
||||||
|
"key": key,
|
||||||
|
"restart": int(payload.get("restart") or 0),
|
||||||
|
"filename": str(payload.get("filename") or "pveproxy-ssl.pem"),
|
||||||
|
}
|
||||||
|
info = list(certs.get("info") or [])
|
||||||
|
info = [item for item in info if item.get("filename") != certs["custom"]["filename"]]
|
||||||
|
info.append(
|
||||||
|
{
|
||||||
|
"filename": certs["custom"]["filename"],
|
||||||
|
"fingerprint": secrets.token_hex(20),
|
||||||
|
"issuer": "CN=Simulator",
|
||||||
|
"subject": "CN=pve.local",
|
||||||
|
"notbefore": int(time.time()) - 86_400,
|
||||||
|
"notafter": int(time.time()) + 365 * 86_400,
|
||||||
|
"san": ["DNS:pve.local"],
|
||||||
|
"public-key-type": "rsa",
|
||||||
|
"public-key-bits": 2048,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
certs["info"] = info
|
||||||
|
ops["certificates"] = certs
|
||||||
|
await save_node_ops(request, node, ops)
|
||||||
|
|
||||||
|
async def certificates_info(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
certs = _certificates(ops)
|
||||||
|
info = list(certs.get("info") or [])
|
||||||
|
custom = _public_cert(
|
||||||
|
certs.get("custom") if isinstance(certs.get("custom"), dict) else None
|
||||||
|
)
|
||||||
|
if custom and not any(item.get("filename") == custom.get("filename") for item in info):
|
||||||
|
info.append(
|
||||||
|
{
|
||||||
|
"filename": custom.get("filename", "pveproxy-ssl.pem"),
|
||||||
|
"fingerprint": secrets.token_hex(20),
|
||||||
|
"issuer": "CN=Custom",
|
||||||
|
"subject": "CN=pve.local",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
certs["info"] = info
|
||||||
|
ops["certificates"] = certs
|
||||||
|
await save_node_ops(request, node, ops)
|
||||||
|
return [dict(item) for item in info]
|
||||||
|
|
||||||
|
async def scan_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
await require_node(request, str(values(inputs)["node"]))
|
||||||
|
return subdirs("cifs", "iscsi", "lvm", "lvmthin", "nfs", "pbs", "zfs")
|
||||||
|
|
||||||
|
async def scan_kind(
|
||||||
|
request: Request, inputs: dict[str, Any], kind: str
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
scan = _scan_cache(ops)
|
||||||
|
ops["scan"] = scan
|
||||||
|
await save_node_ops(request, node, ops)
|
||||||
|
items = scan.get(kind, [])
|
||||||
|
return [dict(item) for item in items] if isinstance(items, list) else []
|
||||||
|
|
||||||
|
async def capabilities_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
await require_node(request, str(values(inputs)["node"]))
|
||||||
|
return subdirs("qemu")
|
||||||
|
|
||||||
|
async def capabilities_qemu(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
await require_node(request, str(values(inputs)["node"]))
|
||||||
|
return subdirs("cpu", "cpu-flags", "machines", "migration")
|
||||||
|
|
||||||
|
async def capabilities_cpu(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
await require_node(request, str(values(inputs)["node"]))
|
||||||
|
return [
|
||||||
|
{"name": "host", "vendor": "QEMU", "custom": 0},
|
||||||
|
{"name": "x86-64-v2-AES", "vendor": "QEMU", "custom": 0},
|
||||||
|
{"name": "kvm64", "vendor": "QEMU", "custom": 0},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def capabilities_cpu_flags(
|
||||||
|
request: Request, inputs: dict[str, Any]
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
await require_node(request, str(values(inputs)["node"]))
|
||||||
|
return [
|
||||||
|
{"name": "aes", "introduces": "Westmere"},
|
||||||
|
{"name": "avx", "introduces": "SandyBridge"},
|
||||||
|
{"name": "avx2", "introduces": "Haswell"},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def capabilities_machines(
|
||||||
|
request: Request, inputs: dict[str, Any]
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
await require_node(request, str(values(inputs)["node"]))
|
||||||
|
return [
|
||||||
|
{"id": "pc-i440fx-9.0", "type": "i440fx", "version": "9.0"},
|
||||||
|
{"id": "pc-q35-9.0", "type": "q35", "version": "9.0"},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def capabilities_migration(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
await require_node(request, str(values(inputs)["node"]))
|
||||||
|
return {"network": "", "type": "secure", "enabled": 1}
|
||||||
|
|
||||||
|
async def hardware_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
await require_node(request, str(values(inputs)["node"]))
|
||||||
|
return subdirs("pci", "usb")
|
||||||
|
|
||||||
|
async def hardware_pci(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
hardware = _hardware(ops)
|
||||||
|
ops["hardware"] = hardware
|
||||||
|
await save_node_ops(request, node, ops)
|
||||||
|
return [dict(item) for item in hardware.get("pci", [])]
|
||||||
|
|
||||||
|
async def hardware_pci_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
pci_id = str(payload.get("pci-id-or-mapping") or payload.get("pciid") or "")
|
||||||
|
await require_node(request, node)
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
for item in _hardware(ops).get("pci", []):
|
||||||
|
if str(item.get("id")) == pci_id:
|
||||||
|
return dict(item)
|
||||||
|
raise ApiError(404, "pci device does not exist")
|
||||||
|
|
||||||
|
async def hardware_pci_mdev(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
pci_id = str(payload.get("pci-id-or-mapping") or payload.get("pciid") or "")
|
||||||
|
await require_node(request, node)
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
hardware = _hardware(ops)
|
||||||
|
mdev = hardware.get("mdev", {})
|
||||||
|
items = mdev.get(pci_id, []) if isinstance(mdev, dict) else []
|
||||||
|
return [dict(item) for item in items] if isinstance(items, list) else []
|
||||||
|
|
||||||
|
async def hardware_usb(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
hardware = _hardware(ops)
|
||||||
|
ops["hardware"] = hardware
|
||||||
|
await save_node_ops(request, node, ops)
|
||||||
|
return [dict(item) for item in hardware.get("usb", [])]
|
||||||
|
|
||||||
|
async def subscription_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
public = dict(_subscription(ops))
|
||||||
|
public.pop("key", None)
|
||||||
|
return public
|
||||||
|
|
||||||
|
async def subscription_mutate(
|
||||||
|
request: Request, inputs: dict[str, Any]
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
current = _subscription(ops)
|
||||||
|
method = request.method.upper()
|
||||||
|
if method == "DELETE":
|
||||||
|
ops["subscription"] = copy.deepcopy(DEFAULT_SUBSCRIPTION)
|
||||||
|
await save_node_ops(request, node, ops)
|
||||||
|
return None
|
||||||
|
if method == "POST":
|
||||||
|
current["checktime"] = int(time.time())
|
||||||
|
current["status"] = current.get("status") or "Active"
|
||||||
|
ops["subscription"] = current
|
||||||
|
await save_node_ops(request, node, ops)
|
||||||
|
return dict(current)
|
||||||
|
key = str(payload.get("key") or current.get("key") or "")
|
||||||
|
updated = {
|
||||||
|
**current,
|
||||||
|
**{k: v for k, v in payload.items() if k not in {"node", "delete", "digest"}},
|
||||||
|
"key": key,
|
||||||
|
"status": "Active" if key else current.get("status", "notfound"),
|
||||||
|
"message": "OK" if key else current.get("message", "There is no subscription key"),
|
||||||
|
}
|
||||||
|
ops["subscription"] = updated
|
||||||
|
await save_node_ops(request, node, ops)
|
||||||
|
public = dict(updated)
|
||||||
|
public.pop("key", None)
|
||||||
|
return public
|
||||||
|
|
||||||
|
async def aplinfo_get(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
items = ops.get("aplinfo")
|
||||||
|
if not isinstance(items, list):
|
||||||
|
items = [
|
||||||
|
{
|
||||||
|
"package": "alpine-3-standard",
|
||||||
|
"section": "system",
|
||||||
|
"type": "lxc",
|
||||||
|
"version": "3.20",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
ops["aplinfo"] = items
|
||||||
|
await save_node_ops(request, node, ops)
|
||||||
|
return [dict(item) for item in items]
|
||||||
|
|
||||||
|
async def aplinfo_download(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
downloads = list(ops.get("aplinfo_downloads") or [])
|
||||||
|
downloads.append(
|
||||||
|
{
|
||||||
|
"template": str(payload.get("template") or payload.get("storage") or "unknown"),
|
||||||
|
"at": int(time.time()),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ops["aplinfo_downloads"] = downloads
|
||||||
|
await save_node_ops(request, node, ops)
|
||||||
|
return await _node_task(request, node=node, task_type="download", worker="download")
|
||||||
|
|
||||||
|
async def apt_repositories_mutate(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
apt = ops.setdefault("apt", copy.deepcopy(default_node_ops()["apt"]))
|
||||||
|
repositories = list(apt.get("repositories") or [])
|
||||||
|
method = request.method.upper()
|
||||||
|
if method == "POST":
|
||||||
|
entry = {
|
||||||
|
key: value
|
||||||
|
for key, value in payload.items()
|
||||||
|
if key not in {"node", "delete", "digest"}
|
||||||
|
}
|
||||||
|
entry.setdefault("path", f"/etc/apt/sources.list.d/sim-{secrets.token_hex(2)}.list")
|
||||||
|
entry.setdefault("enabled", 1)
|
||||||
|
repositories.append(entry)
|
||||||
|
else:
|
||||||
|
path = payload.get("path")
|
||||||
|
handle = payload.get("handle")
|
||||||
|
index = payload.get("index")
|
||||||
|
updated: list[dict[str, Any]] = []
|
||||||
|
for idx, item in enumerate(repositories):
|
||||||
|
match = False
|
||||||
|
if path is not None and item.get("path") == path:
|
||||||
|
match = True
|
||||||
|
if handle is not None and item.get("handle") == handle:
|
||||||
|
match = True
|
||||||
|
if index is not None and idx == int(index):
|
||||||
|
match = True
|
||||||
|
if match or (path is None and handle is None and index is None and idx == 0):
|
||||||
|
merged = {
|
||||||
|
**item,
|
||||||
|
**{
|
||||||
|
key: value
|
||||||
|
for key, value in payload.items()
|
||||||
|
if key not in {"node", "delete", "digest", "path", "handle", "index"}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
updated.append(merged)
|
||||||
|
else:
|
||||||
|
updated.append(item)
|
||||||
|
repositories = updated
|
||||||
|
apt["repositories"] = repositories
|
||||||
|
ops["apt"] = apt
|
||||||
|
await save_node_ops(request, node, ops)
|
||||||
|
|
||||||
|
async def node_config_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
config = ops.get("config")
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
config = copy.deepcopy(DEFAULT_CONFIG)
|
||||||
|
ops["config"] = config
|
||||||
|
await save_node_ops(request, node, ops)
|
||||||
|
return dict(config)
|
||||||
|
|
||||||
|
async def node_config_put(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
config = dict(ops.get("config") or DEFAULT_CONFIG)
|
||||||
|
config.update(
|
||||||
|
{
|
||||||
|
key: value
|
||||||
|
for key, value in payload.items()
|
||||||
|
if key not in {"node", "digest", "delete"}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ops["config"] = config
|
||||||
|
await save_node_ops(request, node, ops)
|
||||||
|
return config
|
||||||
|
|
||||||
|
async def dns_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
dns = ops.get("dns")
|
||||||
|
if not isinstance(dns, dict):
|
||||||
|
dns = copy.deepcopy(DEFAULT_DNS)
|
||||||
|
ops["dns"] = dns
|
||||||
|
await save_node_ops(request, node, ops)
|
||||||
|
return dict(dns)
|
||||||
|
|
||||||
|
async def dns_put(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
dns = dict(ops.get("dns") or DEFAULT_DNS)
|
||||||
|
dns.update(
|
||||||
|
{
|
||||||
|
key: value
|
||||||
|
for key, value in payload.items()
|
||||||
|
if key not in {"node", "digest", "delete"}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ops["dns"] = dns
|
||||||
|
await save_node_ops(request, node, ops)
|
||||||
|
return dns
|
||||||
|
|
||||||
|
async def time_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
current = dict(ops.get("time") or DEFAULT_TIME)
|
||||||
|
now = int(time.time())
|
||||||
|
current["time"] = now
|
||||||
|
current["localtime"] = now
|
||||||
|
current.setdefault("timezone", "UTC")
|
||||||
|
ops["time"] = current
|
||||||
|
await save_node_ops(request, node, ops)
|
||||||
|
return current
|
||||||
|
|
||||||
|
async def time_put(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
current = dict(ops.get("time") or DEFAULT_TIME)
|
||||||
|
if "timezone" in payload:
|
||||||
|
current["timezone"] = str(payload["timezone"])
|
||||||
|
now = int(time.time())
|
||||||
|
current["time"] = now
|
||||||
|
current["localtime"] = now
|
||||||
|
ops["time"] = current
|
||||||
|
await save_node_ops(request, node, ops)
|
||||||
|
return current
|
||||||
|
|
||||||
|
async def execute(request: Request, inputs: dict[str, Any]) -> list[str]:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
commands = payload.get("commands") or payload.get("command") or []
|
||||||
|
if isinstance(commands, str):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(commands)
|
||||||
|
commands = parsed if isinstance(parsed, list) else [commands]
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
commands = [commands]
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
log = list(ops.get("execute_log") or [])
|
||||||
|
output: list[str] = []
|
||||||
|
for command in commands:
|
||||||
|
entry = {"command": str(command), "at": int(time.time())}
|
||||||
|
log.append(entry)
|
||||||
|
output.append(f"OK: {command}")
|
||||||
|
ops["execute_log"] = log[-100:]
|
||||||
|
await save_node_ops(request, node, ops)
|
||||||
|
return output
|
||||||
|
|
||||||
|
async def hosts_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
hosts = ops.get("hosts")
|
||||||
|
if not isinstance(hosts, dict):
|
||||||
|
hosts = {
|
||||||
|
"data": f"127.0.0.1 localhost\n10.0.0.10 {node}\n",
|
||||||
|
"digest": secrets.token_hex(8),
|
||||||
|
}
|
||||||
|
ops["hosts"] = hosts
|
||||||
|
await save_node_ops(request, node, ops)
|
||||||
|
return {"data": str(hosts.get("data", "")), "digest": str(hosts.get("digest", ""))}
|
||||||
|
|
||||||
|
async def hosts_post(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
ops["hosts"] = {
|
||||||
|
"data": str(payload.get("data") or ""),
|
||||||
|
"digest": secrets.token_hex(8),
|
||||||
|
}
|
||||||
|
await save_node_ops(request, node, ops)
|
||||||
|
|
||||||
|
async def journal(request: Request, inputs: dict[str, Any]) -> list[str]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
start = int(values(inputs).get("startcursor") or values(inputs).get("start") or 0)
|
||||||
|
limit = int(values(inputs).get("limit") or 50)
|
||||||
|
lines = [
|
||||||
|
f"{index}: {node} systemd[1]: Started simulated service {index}."
|
||||||
|
for index in range(start, start + limit)
|
||||||
|
]
|
||||||
|
return lines
|
||||||
|
|
||||||
|
async def syslog(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
limit = int(values(inputs).get("limit") or 50)
|
||||||
|
return [
|
||||||
|
{"n": index, "t": f"{node} kernel: simulated syslog line {index}"}
|
||||||
|
for index in range(limit)
|
||||||
|
]
|
||||||
|
|
||||||
|
async def netstat(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
return [
|
||||||
|
{"in": 1_000_000, "out": 900_000, "vnet": "vmbr0", "hwaddr": "bc:24:11:00:00:01"},
|
||||||
|
{"in": 500_000, "out": 450_000, "vnet": "vmbr1", "hwaddr": "bc:24:11:00:00:02"},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def report(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
return f"==== Proxmox node report for {node} ====\nuptime: simulated\n"
|
||||||
|
|
||||||
|
async def rrd(_request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
await require_node(_request, str(values(inputs)["node"]))
|
||||||
|
return {"filename": "/var/lib/rrdcached/db/pve-node.rrd"}
|
||||||
|
|
||||||
|
async def rrddata(_request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
await require_node(_request, str(values(inputs)["node"]))
|
||||||
|
now = int(time.time())
|
||||||
|
return [
|
||||||
|
{"time": now - 120, "cpu": 0.05, "memused": 1_000_000_000},
|
||||||
|
{"time": now - 60, "cpu": 0.07, "memused": 1_100_000_000},
|
||||||
|
{"time": now, "cpu": 0.04, "memused": 1_050_000_000},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def startall(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
await _set_guest_status(request, node, "running")
|
||||||
|
return await _node_task(request, node=node, task_type="startall", worker="startall")
|
||||||
|
|
||||||
|
async def stopall(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
await _set_guest_status(request, node, "stopped")
|
||||||
|
return await _node_task(request, node=node, task_type="stopall", worker="stopall")
|
||||||
|
|
||||||
|
async def suspendall(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
await _set_guest_status(request, node, "paused")
|
||||||
|
return await _node_task(request, node=node, task_type="suspendall", worker="suspendall")
|
||||||
|
|
||||||
|
async def migrateall(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
target = str(payload.get("target") or "")
|
||||||
|
await require_node(request, node)
|
||||||
|
if not target:
|
||||||
|
raise ApiError(400, "parameter verification failed - 'target' missing")
|
||||||
|
await _migrate_guests(request, node, target)
|
||||||
|
return await _node_task(request, node=node, task_type="migrateall", worker="migrateall")
|
||||||
|
|
||||||
|
async def status_post(request: Request, inputs: dict[str, Any]) -> str | None:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
command = str(payload.get("command") or "reboot")
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
ops["last_status_command"] = {"command": command, "at": int(time.time())}
|
||||||
|
await save_node_ops(request, node, ops)
|
||||||
|
return await _node_task(request, node=node, task_type=command, worker=command)
|
||||||
|
|
||||||
|
async def wakeonlan(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
ops["wakeonlan"] = {"at": int(time.time())}
|
||||||
|
await save_node_ops(request, node, ops)
|
||||||
|
return "OK"
|
||||||
|
|
||||||
|
async def _shell_proxy(request: Request, inputs: dict[str, Any], kind: str) -> dict[str, Any]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
payload = {
|
||||||
|
"port": 5900 if kind == "vnc" else 22 if kind == "term" else 3128,
|
||||||
|
"ticket": secrets.token_urlsafe(24),
|
||||||
|
"user": str(getattr(request.state, "principal", "root@pam")),
|
||||||
|
"upid": f"UPID:{node}:{secrets.token_hex(4)}:{kind}shell:0:root@pam:",
|
||||||
|
}
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
shells = ops.setdefault("shells", {})
|
||||||
|
shells[kind] = {key: value for key, value in payload.items() if key != "ticket"}
|
||||||
|
ops["shells"] = shells
|
||||||
|
await save_node_ops(request, node, ops)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
async def spiceshell(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await _shell_proxy(request, inputs, "spice")
|
||||||
|
|
||||||
|
async def termproxy(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await _shell_proxy(request, inputs, "term")
|
||||||
|
|
||||||
|
async def vncshell(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await _shell_proxy(request, inputs, "vnc")
|
||||||
|
|
||||||
|
async def network_reload(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
if not isinstance(ops.get("network"), list):
|
||||||
|
ops["network"] = copy.deepcopy(default_node_ops()["network"])
|
||||||
|
ops["network_applied"] = False
|
||||||
|
await save_node_ops(request, node, ops)
|
||||||
|
|
||||||
|
async def query_oci_repo_tags(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
repo = str(values(inputs).get("repo") or "library/alpine")
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
cache = ops.setdefault("oci_tags", {})
|
||||||
|
if repo not in cache:
|
||||||
|
cache[repo] = [{"tag": "latest"}, {"tag": "3.20"}]
|
||||||
|
ops["oci_tags"] = cache
|
||||||
|
await save_node_ops(request, node, ops)
|
||||||
|
return [dict(item) for item in cache[repo]]
|
||||||
|
|
||||||
|
async def query_url_metadata(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
url = str(values(inputs).get("url") or "")
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
cache = ops.setdefault("url_metadata", {})
|
||||||
|
if url not in cache:
|
||||||
|
cache[url] = {
|
||||||
|
"filename": url.rsplit("/", 1)[-1] or "download.bin",
|
||||||
|
"mimetype": "application/octet-stream",
|
||||||
|
"size": 1024,
|
||||||
|
}
|
||||||
|
ops["url_metadata"] = cache
|
||||||
|
await save_node_ops(request, node, ops)
|
||||||
|
return dict(cache[url])
|
||||||
|
|
||||||
|
async def vncwebsocket(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
ops = await load_node_ops(request, node)
|
||||||
|
shell = (ops.get("shells") or {}).get("vnc") or {"port": 5900}
|
||||||
|
return {
|
||||||
|
"port": shell.get("port", 5900),
|
||||||
|
"ticket": secrets.token_urlsafe(24),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Disks mutations (GET collections already registered in nodes.py)
|
||||||
|
registry.register(
|
||||||
|
"/nodes/{node}/disks/directory",
|
||||||
|
"POST",
|
||||||
|
lambda request, inputs: disks_create(request, inputs, "directory"),
|
||||||
|
)
|
||||||
|
registry.register(
|
||||||
|
"/nodes/{node}/disks/directory/{name}",
|
||||||
|
"DELETE",
|
||||||
|
lambda request, inputs: disks_delete(request, inputs, "directory"),
|
||||||
|
)
|
||||||
|
registry.register(
|
||||||
|
"/nodes/{node}/disks/lvm",
|
||||||
|
"POST",
|
||||||
|
lambda request, inputs: disks_create(request, inputs, "lvm"),
|
||||||
|
)
|
||||||
|
registry.register(
|
||||||
|
"/nodes/{node}/disks/lvm/{name}",
|
||||||
|
"DELETE",
|
||||||
|
lambda request, inputs: disks_delete(request, inputs, "lvm"),
|
||||||
|
)
|
||||||
|
registry.register(
|
||||||
|
"/nodes/{node}/disks/lvmthin",
|
||||||
|
"POST",
|
||||||
|
lambda request, inputs: disks_create(request, inputs, "lvmthin"),
|
||||||
|
)
|
||||||
|
registry.register(
|
||||||
|
"/nodes/{node}/disks/lvmthin/{name}",
|
||||||
|
"DELETE",
|
||||||
|
lambda request, inputs: disks_delete(request, inputs, "lvmthin"),
|
||||||
|
)
|
||||||
|
registry.register(
|
||||||
|
"/nodes/{node}/disks/zfs",
|
||||||
|
"POST",
|
||||||
|
lambda request, inputs: disks_create(request, inputs, "zfs"),
|
||||||
|
)
|
||||||
|
registry.register("/nodes/{node}/disks/zfs/{name}", "GET", disks_zfs_get)
|
||||||
|
registry.register(
|
||||||
|
"/nodes/{node}/disks/zfs/{name}",
|
||||||
|
"DELETE",
|
||||||
|
lambda request, inputs: disks_delete(request, inputs, "zfs"),
|
||||||
|
)
|
||||||
|
|
||||||
|
registry.register("/nodes/{node}/certificates", "GET", certificates_index)
|
||||||
|
registry.register("/nodes/{node}/certificates/acme", "GET", certificates_acme_index)
|
||||||
|
registry.register(
|
||||||
|
"/nodes/{node}/certificates/acme/certificate", "POST", certificates_acme_mutate
|
||||||
|
)
|
||||||
|
registry.register(
|
||||||
|
"/nodes/{node}/certificates/acme/certificate", "PUT", certificates_acme_mutate
|
||||||
|
)
|
||||||
|
registry.register(
|
||||||
|
"/nodes/{node}/certificates/acme/certificate", "DELETE", certificates_acme_mutate
|
||||||
|
)
|
||||||
|
registry.register("/nodes/{node}/certificates/custom", "POST", certificates_custom)
|
||||||
|
registry.register("/nodes/{node}/certificates/custom", "DELETE", certificates_custom)
|
||||||
|
registry.register("/nodes/{node}/certificates/info", "GET", certificates_info)
|
||||||
|
|
||||||
|
registry.register("/nodes/{node}/scan", "GET", scan_index)
|
||||||
|
registry.register("/nodes/{node}/scan/cifs", "GET", lambda r, i: scan_kind(r, i, "cifs"))
|
||||||
|
registry.register("/nodes/{node}/scan/iscsi", "GET", lambda r, i: scan_kind(r, i, "iscsi"))
|
||||||
|
registry.register("/nodes/{node}/scan/lvm", "GET", lambda r, i: scan_kind(r, i, "lvm"))
|
||||||
|
registry.register("/nodes/{node}/scan/lvmthin", "GET", lambda r, i: scan_kind(r, i, "lvmthin"))
|
||||||
|
registry.register("/nodes/{node}/scan/nfs", "GET", lambda r, i: scan_kind(r, i, "nfs"))
|
||||||
|
registry.register("/nodes/{node}/scan/pbs", "GET", lambda r, i: scan_kind(r, i, "pbs"))
|
||||||
|
registry.register("/nodes/{node}/scan/zfs", "GET", lambda r, i: scan_kind(r, i, "zfs"))
|
||||||
|
|
||||||
|
registry.register("/nodes/{node}/capabilities", "GET", capabilities_index)
|
||||||
|
registry.register("/nodes/{node}/capabilities/qemu", "GET", capabilities_qemu)
|
||||||
|
registry.register("/nodes/{node}/capabilities/qemu/cpu", "GET", capabilities_cpu)
|
||||||
|
registry.register("/nodes/{node}/capabilities/qemu/cpu-flags", "GET", capabilities_cpu_flags)
|
||||||
|
registry.register("/nodes/{node}/capabilities/qemu/machines", "GET", capabilities_machines)
|
||||||
|
registry.register("/nodes/{node}/capabilities/qemu/migration", "GET", capabilities_migration)
|
||||||
|
|
||||||
|
registry.register("/nodes/{node}/hardware", "GET", hardware_index)
|
||||||
|
registry.register("/nodes/{node}/hardware/pci", "GET", hardware_pci)
|
||||||
|
registry.register("/nodes/{node}/hardware/pci/{pci-id-or-mapping}", "GET", hardware_pci_get)
|
||||||
|
registry.register(
|
||||||
|
"/nodes/{node}/hardware/pci/{pci-id-or-mapping}/mdev", "GET", hardware_pci_mdev
|
||||||
|
)
|
||||||
|
registry.register("/nodes/{node}/hardware/usb", "GET", hardware_usb)
|
||||||
|
|
||||||
|
registry.register("/nodes/{node}/subscription", "GET", subscription_get)
|
||||||
|
registry.register("/nodes/{node}/subscription", "PUT", subscription_mutate)
|
||||||
|
registry.register("/nodes/{node}/subscription", "POST", subscription_mutate)
|
||||||
|
registry.register("/nodes/{node}/subscription", "DELETE", subscription_mutate)
|
||||||
|
|
||||||
|
registry.register("/nodes/{node}/aplinfo", "GET", aplinfo_get)
|
||||||
|
registry.register("/nodes/{node}/aplinfo", "POST", aplinfo_download)
|
||||||
|
registry.register("/nodes/{node}/apt/repositories", "POST", apt_repositories_mutate)
|
||||||
|
registry.register("/nodes/{node}/apt/repositories", "PUT", apt_repositories_mutate)
|
||||||
|
registry.register("/nodes/{node}/config", "GET", node_config_get)
|
||||||
|
registry.register("/nodes/{node}/config", "PUT", node_config_put)
|
||||||
|
registry.register("/nodes/{node}/dns", "GET", dns_get)
|
||||||
|
registry.register("/nodes/{node}/dns", "PUT", dns_put)
|
||||||
|
registry.register("/nodes/{node}/time", "GET", time_get)
|
||||||
|
registry.register("/nodes/{node}/time", "PUT", time_put)
|
||||||
|
registry.register("/nodes/{node}/execute", "POST", execute)
|
||||||
|
registry.register("/nodes/{node}/hosts", "GET", hosts_get)
|
||||||
|
registry.register("/nodes/{node}/hosts", "POST", hosts_post)
|
||||||
|
registry.register("/nodes/{node}/journal", "GET", journal)
|
||||||
|
registry.register("/nodes/{node}/syslog", "GET", syslog)
|
||||||
|
registry.register("/nodes/{node}/netstat", "GET", netstat)
|
||||||
|
registry.register("/nodes/{node}/report", "GET", report)
|
||||||
|
registry.register("/nodes/{node}/rrd", "GET", rrd)
|
||||||
|
registry.register("/nodes/{node}/rrddata", "GET", rrddata)
|
||||||
|
registry.register("/nodes/{node}/migrateall", "POST", migrateall)
|
||||||
|
registry.register("/nodes/{node}/startall", "POST", startall)
|
||||||
|
registry.register("/nodes/{node}/stopall", "POST", stopall)
|
||||||
|
registry.register("/nodes/{node}/suspendall", "POST", suspendall)
|
||||||
|
registry.register("/nodes/{node}/status", "POST", status_post)
|
||||||
|
registry.register("/nodes/{node}/wakeonlan", "POST", wakeonlan)
|
||||||
|
registry.register("/nodes/{node}/spiceshell", "POST", spiceshell)
|
||||||
|
registry.register("/nodes/{node}/termproxy", "POST", termproxy)
|
||||||
|
registry.register("/nodes/{node}/vncshell", "POST", vncshell)
|
||||||
|
registry.register("/nodes/{node}/network", "DELETE", network_reload)
|
||||||
|
registry.register("/nodes/{node}/query-oci-repo-tags", "GET", query_oci_repo_tags)
|
||||||
|
registry.register("/nodes/{node}/query-url-metadata", "GET", query_url_metadata)
|
||||||
|
registry.register("/nodes/{node}/vncwebsocket", "GET", vncwebsocket)
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
"""Cluster notifications endpoints and matchers persisted in metadata."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from app.api.errors import ApiError
|
||||||
|
from app.api.registry import HandlerRegistry
|
||||||
|
from app.handlers.common import cluster_metadata, save_cluster_metadata, subdirs, values
|
||||||
|
|
||||||
|
_SECRET_KEYS = frozenset({"token", "password", "secret"})
|
||||||
|
|
||||||
|
DEFAULT_MATCHER_FIELDS = [
|
||||||
|
{"name": "type", "type": "string"},
|
||||||
|
{"name": "hostname", "type": "string"},
|
||||||
|
{"name": "job-id", "type": "string"},
|
||||||
|
{"name": "severity", "type": "string"},
|
||||||
|
]
|
||||||
|
|
||||||
|
DEFAULT_MATCHER_FIELD_VALUES = [
|
||||||
|
{"field": "type", "value": "fencing"},
|
||||||
|
{"field": "type", "value": "package-updates"},
|
||||||
|
{"field": "type", "value": "replication"},
|
||||||
|
{"field": "type", "value": "system-mail"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _public(endpoint: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {key: value for key, value in endpoint.items() if key not in _SECRET_KEYS}
|
||||||
|
|
||||||
|
|
||||||
|
def _notifications(metadata: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
current = metadata.setdefault(
|
||||||
|
"notifications",
|
||||||
|
{
|
||||||
|
"endpoints": {
|
||||||
|
"gotify": {},
|
||||||
|
"sendmail": {},
|
||||||
|
"smtp": {},
|
||||||
|
"webhook": {},
|
||||||
|
},
|
||||||
|
"matchers": {},
|
||||||
|
"tests": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not isinstance(current, dict):
|
||||||
|
current = {
|
||||||
|
"endpoints": {"gotify": {}, "sendmail": {}, "smtp": {}, "webhook": {}},
|
||||||
|
"matchers": {},
|
||||||
|
"tests": [],
|
||||||
|
}
|
||||||
|
metadata["notifications"] = current
|
||||||
|
current.setdefault(
|
||||||
|
"endpoints",
|
||||||
|
{"gotify": {}, "sendmail": {}, "smtp": {}, "webhook": {}},
|
||||||
|
)
|
||||||
|
current.setdefault("matchers", {})
|
||||||
|
current.setdefault("tests", [])
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
def register_notifications_handlers(registry: HandlerRegistry) -> None:
|
||||||
|
async def index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
return subdirs(
|
||||||
|
"endpoints",
|
||||||
|
"matcher-field-values",
|
||||||
|
"matcher-fields",
|
||||||
|
"matchers",
|
||||||
|
"targets",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def endpoints_index(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
return subdirs("gotify", "sendmail", "smtp", "webhook")
|
||||||
|
|
||||||
|
def register_kind(kind: str, create_keys: tuple[str, ...]) -> None:
|
||||||
|
base = f"/cluster/notifications/endpoints/{kind}"
|
||||||
|
|
||||||
|
async def list_endpoints(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
store = _notifications(metadata)["endpoints"].setdefault(kind, {})
|
||||||
|
return [_public({"name": name, **item}) for name, item in sorted(store.items())]
|
||||||
|
|
||||||
|
async def create(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
name = str(payload["name"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
store = _notifications(metadata)["endpoints"].setdefault(kind, {})
|
||||||
|
if name in store:
|
||||||
|
raise ApiError(400, f"{kind} endpoint '{name}' already exists")
|
||||||
|
entry = {key: payload[key] for key in create_keys if key in payload}
|
||||||
|
entry["name"] = name
|
||||||
|
entry.setdefault("disable", 0)
|
||||||
|
store[name] = entry
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
|
||||||
|
async def get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
name = str(values(inputs)["name"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
store = _notifications(metadata)["endpoints"].setdefault(kind, {})
|
||||||
|
if name not in store:
|
||||||
|
raise ApiError(404, f"{kind} endpoint does not exist")
|
||||||
|
return _public({"name": name, **store[name]})
|
||||||
|
|
||||||
|
async def update(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
name = str(payload["name"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
store = _notifications(metadata)["endpoints"].setdefault(kind, {})
|
||||||
|
if name not in store:
|
||||||
|
raise ApiError(404, f"{kind} endpoint does not exist")
|
||||||
|
current = dict(store[name])
|
||||||
|
delete_keys = [
|
||||||
|
item.strip() for item in str(payload.get("delete") or "").split(",") if item.strip()
|
||||||
|
]
|
||||||
|
for key in delete_keys:
|
||||||
|
current.pop(key, None)
|
||||||
|
for key, value in payload.items():
|
||||||
|
if key in {"name", "delete", "digest"}:
|
||||||
|
continue
|
||||||
|
current[key] = value
|
||||||
|
current["name"] = name
|
||||||
|
store[name] = current
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
|
||||||
|
async def delete(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
name = str(values(inputs)["name"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
store = _notifications(metadata)["endpoints"].setdefault(kind, {})
|
||||||
|
if name not in store:
|
||||||
|
raise ApiError(404, f"{kind} endpoint does not exist")
|
||||||
|
del store[name]
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
|
||||||
|
registry.register(base, "GET", list_endpoints)
|
||||||
|
registry.register(base, "POST", create)
|
||||||
|
registry.register(f"{base}/{{name}}", "GET", get)
|
||||||
|
registry.register(f"{base}/{{name}}", "PUT", update)
|
||||||
|
registry.register(f"{base}/{{name}}", "DELETE", delete)
|
||||||
|
|
||||||
|
async def matchers_list(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
store = _notifications(metadata)["matchers"]
|
||||||
|
return [{"name": name, **item} for name, item in sorted(store.items())]
|
||||||
|
|
||||||
|
async def matchers_create(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
name = str(payload["name"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
store = _notifications(metadata)["matchers"]
|
||||||
|
if name in store:
|
||||||
|
raise ApiError(400, f"matcher '{name}' already exists")
|
||||||
|
store[name] = {
|
||||||
|
key: value for key, value in payload.items() if key not in {"delete", "digest"}
|
||||||
|
}
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
|
||||||
|
async def matchers_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
name = str(values(inputs)["name"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
store = _notifications(metadata)["matchers"]
|
||||||
|
if name not in store:
|
||||||
|
raise ApiError(404, "matcher does not exist")
|
||||||
|
return {"name": name, **store[name]}
|
||||||
|
|
||||||
|
async def matchers_update(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
name = str(payload["name"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
store = _notifications(metadata)["matchers"]
|
||||||
|
if name not in store:
|
||||||
|
raise ApiError(404, "matcher does not exist")
|
||||||
|
current = dict(store[name])
|
||||||
|
for key in [
|
||||||
|
item.strip() for item in str(payload.get("delete") or "").split(",") if item.strip()
|
||||||
|
]:
|
||||||
|
current.pop(key, None)
|
||||||
|
for key, value in payload.items():
|
||||||
|
if key in {"name", "delete", "digest"}:
|
||||||
|
continue
|
||||||
|
current[key] = value
|
||||||
|
current["name"] = name
|
||||||
|
store[name] = current
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
|
||||||
|
async def matchers_delete(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
name = str(values(inputs)["name"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
store = _notifications(metadata)["matchers"]
|
||||||
|
if name not in store:
|
||||||
|
raise ApiError(404, "matcher does not exist")
|
||||||
|
del store[name]
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
|
||||||
|
async def matcher_fields(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
return list(DEFAULT_MATCHER_FIELDS)
|
||||||
|
|
||||||
|
async def matcher_field_values(
|
||||||
|
_request: Request, _inputs: dict[str, Any]
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
return list(DEFAULT_MATCHER_FIELD_VALUES)
|
||||||
|
|
||||||
|
async def targets(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
notifications = _notifications(metadata)
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
for kind, store in (notifications.get("endpoints") or {}).items():
|
||||||
|
if not isinstance(store, dict):
|
||||||
|
continue
|
||||||
|
for name, item in store.items():
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"type": kind,
|
||||||
|
"comment": item.get("comment", ""),
|
||||||
|
"disable": int(bool(item.get("disable"))),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def target_test(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
name = str(values(inputs)["name"])
|
||||||
|
metadata = await cluster_metadata(request)
|
||||||
|
notifications = _notifications(metadata)
|
||||||
|
found = False
|
||||||
|
for store in (notifications.get("endpoints") or {}).values():
|
||||||
|
if isinstance(store, dict) and name in store:
|
||||||
|
found = True
|
||||||
|
break
|
||||||
|
if not found:
|
||||||
|
raise ApiError(404, "notification target does not exist")
|
||||||
|
tests = notifications.setdefault("tests", [])
|
||||||
|
if not isinstance(tests, list):
|
||||||
|
tests = notifications["tests"] = []
|
||||||
|
tests.append({"name": name, "tested_at": int(time.time()), "ok": True})
|
||||||
|
await save_cluster_metadata(request, metadata)
|
||||||
|
|
||||||
|
registry.register("/cluster/notifications", "GET", index)
|
||||||
|
registry.register("/cluster/notifications/endpoints", "GET", endpoints_index)
|
||||||
|
register_kind("gotify", ("comment", "disable", "name", "server", "token"))
|
||||||
|
register_kind(
|
||||||
|
"sendmail",
|
||||||
|
("author", "comment", "disable", "from-address", "mailto", "mailto-user", "name"),
|
||||||
|
)
|
||||||
|
register_kind(
|
||||||
|
"smtp",
|
||||||
|
(
|
||||||
|
"author",
|
||||||
|
"comment",
|
||||||
|
"disable",
|
||||||
|
"from-address",
|
||||||
|
"mailto",
|
||||||
|
"mailto-user",
|
||||||
|
"mode",
|
||||||
|
"name",
|
||||||
|
"password",
|
||||||
|
"port",
|
||||||
|
"server",
|
||||||
|
"username",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
register_kind(
|
||||||
|
"webhook",
|
||||||
|
("body", "comment", "disable", "header", "method", "name", "secret", "url"),
|
||||||
|
)
|
||||||
|
registry.register("/cluster/notifications/matchers", "GET", matchers_list)
|
||||||
|
registry.register("/cluster/notifications/matchers", "POST", matchers_create)
|
||||||
|
registry.register("/cluster/notifications/matchers/{name}", "GET", matchers_get)
|
||||||
|
registry.register("/cluster/notifications/matchers/{name}", "PUT", matchers_update)
|
||||||
|
registry.register("/cluster/notifications/matchers/{name}", "DELETE", matchers_delete)
|
||||||
|
registry.register("/cluster/notifications/matcher-fields", "GET", matcher_fields)
|
||||||
|
registry.register("/cluster/notifications/matcher-field-values", "GET", matcher_field_values)
|
||||||
|
registry.register("/cluster/notifications/targets", "GET", targets)
|
||||||
|
registry.register("/cluster/notifications/targets/{name}/test", "POST", target_test)
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""Resource pool semantic handlers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from app.api.errors import ApiError
|
||||||
|
from app.api.registry import HandlerRegistry
|
||||||
|
from app.handlers.common import database, state, values
|
||||||
|
from app.simulation.seed import CLUSTER_ID, stable_id
|
||||||
|
|
||||||
|
|
||||||
|
async def _pool_members(request: Request, pool_id: uuid.UUID) -> list[str]:
|
||||||
|
rows = await database(request).pool.fetch(
|
||||||
|
"""SELECT r.external_id FROM pool_members pm
|
||||||
|
JOIN resources r ON r.id = pm.resource_id
|
||||||
|
WHERE pm.pool_id=$1 ORDER BY r.external_id::integer""",
|
||||||
|
pool_id,
|
||||||
|
)
|
||||||
|
return [str(row["external_id"]) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def register_pool_handlers(registry: HandlerRegistry) -> None:
|
||||||
|
async def pool_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
payload = values(inputs)
|
||||||
|
filter_poolid = payload.get("poolid")
|
||||||
|
rows = await database(request).pool.fetch(
|
||||||
|
"""SELECT id, pool_id, comment, metadata FROM pools
|
||||||
|
WHERE ($1::text IS NULL OR pool_id=$1)
|
||||||
|
ORDER BY pool_id""",
|
||||||
|
str(filter_poolid) if filter_poolid is not None else None,
|
||||||
|
)
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
metadata = state(row["metadata"])
|
||||||
|
members = await _pool_members(request, row["id"])
|
||||||
|
if not members and isinstance(metadata.get("members"), list):
|
||||||
|
members = [str(item) for item in metadata["members"]]
|
||||||
|
item: dict[str, Any] = {
|
||||||
|
"poolid": str(row["pool_id"]),
|
||||||
|
"members": members,
|
||||||
|
}
|
||||||
|
if row["comment"] is not None:
|
||||||
|
item["comment"] = str(row["comment"])
|
||||||
|
elif metadata.get("comment"):
|
||||||
|
item["comment"] = str(metadata["comment"])
|
||||||
|
result.append(item)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def pool_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
items = await pool_list(request, inputs)
|
||||||
|
if not items:
|
||||||
|
raise ApiError(404, "pool does not exist")
|
||||||
|
return items[0]
|
||||||
|
|
||||||
|
async def pool_create(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
poolid = str(payload["poolid"])
|
||||||
|
exists = await database(request).pool.fetchval(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM pools WHERE pool_id=$1)",
|
||||||
|
poolid,
|
||||||
|
)
|
||||||
|
if exists:
|
||||||
|
raise ApiError(409, "pool already exists")
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"""INSERT INTO pools(id, cluster_id, pool_id, comment, metadata)
|
||||||
|
VALUES($1, $2, $3, $4, $5::jsonb)""",
|
||||||
|
stable_id(f"pool:{poolid}"),
|
||||||
|
CLUSTER_ID,
|
||||||
|
poolid,
|
||||||
|
payload.get("comment"),
|
||||||
|
json.dumps({"members": []}, sort_keys=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def pool_update(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
poolid = str(payload["poolid"])
|
||||||
|
pool_row = await database(request).pool.fetchrow(
|
||||||
|
"SELECT id FROM pools WHERE pool_id=$1",
|
||||||
|
poolid,
|
||||||
|
)
|
||||||
|
if pool_row is None:
|
||||||
|
raise ApiError(404, "pool does not exist")
|
||||||
|
if payload.get("comment") is not None:
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"UPDATE pools SET comment=$2 WHERE pool_id=$1",
|
||||||
|
poolid,
|
||||||
|
payload.get("comment"),
|
||||||
|
)
|
||||||
|
if "vms" in payload:
|
||||||
|
vmids = [item.strip() for item in str(payload["vms"]).split(",") if item.strip()]
|
||||||
|
for vmid in vmids:
|
||||||
|
resource = await database(request).pool.fetchrow(
|
||||||
|
"""SELECT id FROM resources
|
||||||
|
WHERE kind IN ('qemu', 'lxc') AND external_id=$1""",
|
||||||
|
vmid,
|
||||||
|
)
|
||||||
|
if resource is None:
|
||||||
|
continue
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"""INSERT INTO pool_members(pool_id, resource_id)
|
||||||
|
VALUES($1, $2) ON CONFLICT DO NOTHING""",
|
||||||
|
pool_row["id"],
|
||||||
|
resource["id"],
|
||||||
|
)
|
||||||
|
if "delete" in payload:
|
||||||
|
vmids = [item.strip() for item in str(payload["delete"]).split(",") if item.strip()]
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"""DELETE FROM pool_members pm USING resources r
|
||||||
|
WHERE pm.pool_id=$1 AND pm.resource_id=r.id AND r.external_id = ANY($2::text[])""",
|
||||||
|
pool_row["id"],
|
||||||
|
vmids,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def pool_delete(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
poolid = str(values(inputs)["poolid"])
|
||||||
|
status = await database(request).pool.execute(
|
||||||
|
"DELETE FROM pools WHERE pool_id=$1",
|
||||||
|
poolid,
|
||||||
|
)
|
||||||
|
if status != "DELETE 1":
|
||||||
|
raise ApiError(404, "pool does not exist")
|
||||||
|
|
||||||
|
registry.register("/pools", "GET", pool_list)
|
||||||
|
registry.register("/pools", "POST", pool_create)
|
||||||
|
registry.register("/pools", "PUT", pool_update)
|
||||||
|
registry.register("/pools", "DELETE", pool_delete)
|
||||||
|
registry.register("/pools/{poolid}", "GET", pool_get)
|
||||||
|
registry.register("/pools/{poolid}", "PUT", pool_update)
|
||||||
|
registry.register("/pools/{poolid}", "DELETE", pool_delete)
|
||||||
@@ -0,0 +1,865 @@
|
|||||||
|
"""Basic persistent QEMU and task semantic handlers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from app.api.errors import ApiError
|
||||||
|
from app.api.registry import HandlerRegistry
|
||||||
|
from app.db.pool import AsyncpgDatabase
|
||||||
|
from app.db.primitives import ConflictError
|
||||||
|
from app.handlers.common import require_node, subdirs
|
||||||
|
from app.simulation.transitions import InvalidTransitionError, VmState, plan_transition
|
||||||
|
from app.tasks.repository import TaskRepository
|
||||||
|
from app.tasks.upid import Upid
|
||||||
|
|
||||||
|
|
||||||
|
def _database(request: Request) -> AsyncpgDatabase:
|
||||||
|
return cast(AsyncpgDatabase, request.app.state.database)
|
||||||
|
|
||||||
|
|
||||||
|
def _values(inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return cast(dict[str, Any], inputs["values"])
|
||||||
|
|
||||||
|
|
||||||
|
def _state(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 register_qemu_handlers(registry: HandlerRegistry) -> None:
|
||||||
|
async def qemu_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
node = str(_values(inputs)["node"])
|
||||||
|
rows = await _database(request).pool.fetch(
|
||||||
|
"""SELECT r.external_id::integer AS vmid, r.state
|
||||||
|
FROM resources r JOIN nodes n ON n.id=r.node_id
|
||||||
|
WHERE n.name=$1 AND r.kind='qemu' ORDER BY r.external_id::integer""",
|
||||||
|
node,
|
||||||
|
)
|
||||||
|
return [{"vmid": int(row["vmid"]), **_state(row["state"])} for row in rows]
|
||||||
|
|
||||||
|
async def qemu_status_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
payload = _values(inputs)
|
||||||
|
await _qemu_resource(request, str(payload["node"]), str(payload["vmid"]))
|
||||||
|
return subdirs(
|
||||||
|
"current",
|
||||||
|
"reboot",
|
||||||
|
"reset",
|
||||||
|
"resume",
|
||||||
|
"shutdown",
|
||||||
|
"start",
|
||||||
|
"stop",
|
||||||
|
"suspend",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def qemu_status_current(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = _values(inputs)
|
||||||
|
node, vmid = str(payload["node"]), int(payload["vmid"])
|
||||||
|
resource = await _qemu_resource(request, node, str(vmid))
|
||||||
|
vm_state = _state(resource["state"])
|
||||||
|
config = _state(resource["config"])
|
||||||
|
status = str(vm_state.get("status", "stopped"))
|
||||||
|
running = status in {"running", "paused"}
|
||||||
|
memory_mb = int(config.get("memory", config.get("mem", 2048)))
|
||||||
|
maxmem = memory_mb * 2**20
|
||||||
|
mem_used = int(vm_state.get("mem", maxmem // 2 if running else 0))
|
||||||
|
uptime = int(
|
||||||
|
vm_state.get(
|
||||||
|
"uptime",
|
||||||
|
int(
|
||||||
|
await _database(request).pool.fetchval(
|
||||||
|
"SELECT extract(epoch from now())::bigint"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
% 86_400
|
||||||
|
if running
|
||||||
|
else 0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"vmid": vmid,
|
||||||
|
"name": str(config.get("name", f"vm-{vmid}")),
|
||||||
|
"status": status,
|
||||||
|
"qmpstatus": status if running else "stopped",
|
||||||
|
"lock": str(vm_state.get("lock", "")),
|
||||||
|
"pid": int(vm_state.get("pid", 12_345 if running else 0)),
|
||||||
|
"cpus": int(config.get("cores", config.get("cpus", 1))),
|
||||||
|
"maxmem": maxmem,
|
||||||
|
"mem": mem_used,
|
||||||
|
"balloon": int(vm_state.get("balloon", 0)),
|
||||||
|
"ballooninfo": {
|
||||||
|
"actual": mem_used,
|
||||||
|
"max_mem": maxmem,
|
||||||
|
"mem_swapped_in": 0,
|
||||||
|
"mem_swapped_out": 0,
|
||||||
|
},
|
||||||
|
"uptime": uptime,
|
||||||
|
"template": int(bool(vm_state.get("template", False))),
|
||||||
|
"ha": {"managed": int(vm_state.get("ha_managed", 0))},
|
||||||
|
"agent": 1 if running and str(config.get("agent", "0")).startswith("1") else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def qemu_config(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
node, vmid = str(_values(inputs)["node"]), str(_values(inputs)["vmid"])
|
||||||
|
row = await _database(request).pool.fetchrow(
|
||||||
|
"""SELECT r.state, v.config FROM resources r
|
||||||
|
JOIN nodes n ON n.id=r.node_id
|
||||||
|
JOIN virtual_machines v ON v.resource_id=r.id
|
||||||
|
WHERE n.name=$1 AND r.kind='qemu' AND r.external_id=$2""",
|
||||||
|
node,
|
||||||
|
vmid,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "virtual machine does not exist")
|
||||||
|
return {"vmid": int(vmid), **_state(row["config"]), **_state(row["state"])}
|
||||||
|
|
||||||
|
async def mutate(operation: str, request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
values = _values(inputs)
|
||||||
|
node, vmid = str(values["node"]), str(values["vmid"])
|
||||||
|
database = _database(request)
|
||||||
|
row = await database.pool.fetchrow(
|
||||||
|
"""SELECT r.id, r.state FROM resources r JOIN nodes n ON n.id=r.node_id
|
||||||
|
WHERE n.name=$1 AND r.kind='qemu' AND r.external_id=$2""",
|
||||||
|
node,
|
||||||
|
vmid,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "virtual machine does not exist")
|
||||||
|
current = str(_state(row["state"]).get("status", "stopped"))
|
||||||
|
try:
|
||||||
|
plan_transition(VmState(current), operation)
|
||||||
|
except (InvalidTransitionError, ValueError) as error:
|
||||||
|
raise ApiError(409, f"cannot {operation} VM while it is {current}") from error
|
||||||
|
upid = str(Upid.allocate(node, f"qm{operation}", vmid, str(request.state.principal)))
|
||||||
|
try:
|
||||||
|
task = await TaskRepository(database.pool).create(
|
||||||
|
upid=upid,
|
||||||
|
task_type=f"qemu-{operation}",
|
||||||
|
payload={"node": node, "vmid": vmid, "resource_id": str(row["id"])},
|
||||||
|
resource_key=f"qemu:{vmid}",
|
||||||
|
idempotency_key=request.headers.get("Idempotency-Key"),
|
||||||
|
)
|
||||||
|
except ConflictError as error:
|
||||||
|
raise ApiError(409, str(error)) from error
|
||||||
|
return task.upid
|
||||||
|
|
||||||
|
async def create(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
values = _values(inputs)
|
||||||
|
node, vmid = str(values["node"]), int(values["vmid"])
|
||||||
|
database = _database(request)
|
||||||
|
if not await database.pool.fetchval(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)", node
|
||||||
|
):
|
||||||
|
raise ApiError(404, "node does not exist")
|
||||||
|
if await database.pool.fetchval(
|
||||||
|
"""SELECT EXISTS(SELECT 1 FROM resources
|
||||||
|
WHERE external_id=$1 AND kind IN ('qemu','lxc'))""",
|
||||||
|
str(vmid),
|
||||||
|
):
|
||||||
|
raise ApiError(409, "VMID already exists")
|
||||||
|
config = {
|
||||||
|
key: value
|
||||||
|
for key, value in values.items()
|
||||||
|
if key not in {"node", "vmid", "force", "archive", "start"}
|
||||||
|
}
|
||||||
|
return await _create_task(
|
||||||
|
request,
|
||||||
|
node=node,
|
||||||
|
vmid=str(vmid),
|
||||||
|
task_type="qemu-create",
|
||||||
|
payload={"node": node, "vmid": vmid, "config": config},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def update(request: Request, inputs: dict[str, Any], *, asynchronous: bool) -> str | None:
|
||||||
|
values = _values(inputs)
|
||||||
|
node, vmid = str(values["node"]), str(values["vmid"])
|
||||||
|
database = _database(request)
|
||||||
|
row = await database.pool.fetchrow(
|
||||||
|
"""SELECT r.id, r.version, r.state, v.config FROM resources r
|
||||||
|
JOIN nodes n ON n.id=r.node_id
|
||||||
|
JOIN virtual_machines v ON v.resource_id=r.id
|
||||||
|
WHERE n.name=$1 AND r.kind='qemu' AND r.external_id=$2""",
|
||||||
|
node,
|
||||||
|
vmid,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "virtual machine does not exist")
|
||||||
|
control = {"node", "vmid", "digest", "delete", "revert", "skiplock", "background_delay"}
|
||||||
|
provided = frozenset(str(item) for item in inputs.get("provided", values))
|
||||||
|
changes = {
|
||||||
|
key: value for key, value in values.items() if key in provided and key not in control
|
||||||
|
}
|
||||||
|
delete = str(values.get("delete", "")) if "delete" in provided else ""
|
||||||
|
if asynchronous:
|
||||||
|
return await _create_task(
|
||||||
|
request,
|
||||||
|
node=node,
|
||||||
|
vmid=vmid,
|
||||||
|
task_type="qemu-update",
|
||||||
|
payload={
|
||||||
|
"node": node,
|
||||||
|
"vmid": vmid,
|
||||||
|
"resource_id": str(row["id"]),
|
||||||
|
"changes": changes,
|
||||||
|
"delete": delete,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
state = _state(row["state"])
|
||||||
|
config = _state(row["config"])
|
||||||
|
state.update(changes)
|
||||||
|
config.update(changes)
|
||||||
|
for key in delete.split(","):
|
||||||
|
if key:
|
||||||
|
state.pop(key, None)
|
||||||
|
config.pop(key, None)
|
||||||
|
status = await database.pool.execute(
|
||||||
|
"""UPDATE resources SET state=$3::jsonb, version=version+1,
|
||||||
|
updated_at=now() WHERE id=$1 AND version=$2""",
|
||||||
|
row["id"],
|
||||||
|
row["version"],
|
||||||
|
json.dumps(state, sort_keys=True),
|
||||||
|
)
|
||||||
|
if status != "UPDATE 1":
|
||||||
|
raise ApiError(409, "configuration changed concurrently")
|
||||||
|
await database.pool.execute(
|
||||||
|
"""UPDATE virtual_machines SET config=$2::jsonb
|
||||||
|
WHERE resource_id=$1""",
|
||||||
|
row["id"],
|
||||||
|
json.dumps(config, sort_keys=True),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def update_async(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
result = await update(request, inputs, asynchronous=True)
|
||||||
|
if not isinstance(result, str):
|
||||||
|
raise RuntimeError("async QEMU update did not create a task")
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def update_sync(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
await update(request, inputs, asynchronous=False)
|
||||||
|
|
||||||
|
async def delete(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
values = _values(inputs)
|
||||||
|
node, vmid = str(values["node"]), str(values["vmid"])
|
||||||
|
database = _database(request)
|
||||||
|
row = await database.pool.fetchrow(
|
||||||
|
"""SELECT r.id, r.state FROM resources r JOIN nodes n ON n.id=r.node_id
|
||||||
|
WHERE n.name=$1 AND r.kind='qemu' AND r.external_id=$2""",
|
||||||
|
node,
|
||||||
|
vmid,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "virtual machine does not exist")
|
||||||
|
if str(_state(row["state"]).get("status")) != "stopped":
|
||||||
|
raise ApiError(409, "cannot delete a running virtual machine")
|
||||||
|
return await _create_task(
|
||||||
|
request,
|
||||||
|
node=node,
|
||||||
|
vmid=vmid,
|
||||||
|
task_type="qemu-delete",
|
||||||
|
payload={"node": node, "vmid": vmid, "resource_id": str(row["id"])},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def start(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
return await mutate("start", request, inputs)
|
||||||
|
|
||||||
|
async def stop(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
return await mutate("stop", request, inputs)
|
||||||
|
|
||||||
|
async def shutdown(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
return await mutate("shutdown", request, inputs)
|
||||||
|
|
||||||
|
async def reboot(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
return await mutate("reboot", request, inputs)
|
||||||
|
|
||||||
|
async def reset(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
return await mutate("reset", request, inputs)
|
||||||
|
|
||||||
|
async def suspend(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
return await mutate("suspend", request, inputs)
|
||||||
|
|
||||||
|
async def resume(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
return await mutate("resume", request, inputs)
|
||||||
|
|
||||||
|
async def snapshot_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
values = _values(inputs)
|
||||||
|
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
|
||||||
|
rows = await _database(request).pool.fetch(
|
||||||
|
"""SELECT name, parent_name, description, created_at FROM snapshots
|
||||||
|
WHERE resource_id=$1 ORDER BY created_at, name""",
|
||||||
|
resource["id"],
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": row["name"],
|
||||||
|
"parent": row["parent_name"],
|
||||||
|
"description": row["description"] or "",
|
||||||
|
"snaptime": int(row["created_at"].timestamp()),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
async def snapshot_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
values = _values(inputs)
|
||||||
|
row = await _snapshot(request, values)
|
||||||
|
state = _state(row["state"])
|
||||||
|
return {
|
||||||
|
"name": row["name"],
|
||||||
|
"parent": row["parent_name"],
|
||||||
|
"description": row["description"] or "",
|
||||||
|
"snaptime": int(row["created_at"].timestamp()),
|
||||||
|
**state,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def snapshot_config(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
row = await _snapshot(request, _values(inputs))
|
||||||
|
return {"description": row["description"] or "", **_state(row["state"])}
|
||||||
|
|
||||||
|
async def snapshot_update(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
values = _values(inputs)
|
||||||
|
row = await _snapshot(request, values)
|
||||||
|
await _database(request).pool.execute(
|
||||||
|
"UPDATE snapshots SET description=$2 WHERE id=$1",
|
||||||
|
row["id"],
|
||||||
|
str(values.get("description", "")),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def snapshot_task(operation: str, request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
values = _values(inputs)
|
||||||
|
node, vmid, snapname = (
|
||||||
|
str(values["node"]),
|
||||||
|
str(values["vmid"]),
|
||||||
|
str(values["snapname"]),
|
||||||
|
)
|
||||||
|
resource = await _qemu_resource(request, node, vmid)
|
||||||
|
if operation == "snapshot-create":
|
||||||
|
exists = await _database(request).pool.fetchval(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM snapshots WHERE resource_id=$1 AND name=$2)",
|
||||||
|
resource["id"],
|
||||||
|
snapname,
|
||||||
|
)
|
||||||
|
if exists:
|
||||||
|
raise ApiError(409, "snapshot already exists")
|
||||||
|
else:
|
||||||
|
await _snapshot(request, values)
|
||||||
|
return await _create_task(
|
||||||
|
request,
|
||||||
|
node=node,
|
||||||
|
vmid=vmid,
|
||||||
|
task_type=f"qemu-{operation}",
|
||||||
|
payload={
|
||||||
|
"node": node,
|
||||||
|
"vmid": vmid,
|
||||||
|
"resource_id": str(resource["id"]),
|
||||||
|
"snapname": snapname,
|
||||||
|
"description": str(values.get("description", "")),
|
||||||
|
"vmstate": bool(values.get("vmstate", False)),
|
||||||
|
"start": bool(values.get("start", False)),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def snapshot_create(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
return await snapshot_task("snapshot-create", request, inputs)
|
||||||
|
|
||||||
|
async def snapshot_delete(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
return await snapshot_task("snapshot-delete", request, inputs)
|
||||||
|
|
||||||
|
async def snapshot_rollback(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
return await snapshot_task("snapshot-rollback", request, inputs)
|
||||||
|
|
||||||
|
async def clone(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
values = _values(inputs)
|
||||||
|
node, vmid, newid = str(values["node"]), str(values["vmid"]), str(values["newid"])
|
||||||
|
source = await _qemu_resource(request, node, vmid)
|
||||||
|
if await _database(request).pool.fetchval(
|
||||||
|
"""SELECT EXISTS(SELECT 1 FROM resources
|
||||||
|
WHERE external_id=$1 AND kind IN ('qemu','lxc'))""",
|
||||||
|
newid,
|
||||||
|
):
|
||||||
|
raise ApiError(409, "VMID already exists")
|
||||||
|
target = str(values.get("target") or node)
|
||||||
|
if not await _database(request).pool.fetchval(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)", target
|
||||||
|
):
|
||||||
|
raise ApiError(404, "target node does not exist")
|
||||||
|
return await _create_task(
|
||||||
|
request,
|
||||||
|
node=target,
|
||||||
|
vmid=newid,
|
||||||
|
task_type="qemu-clone",
|
||||||
|
payload={
|
||||||
|
"source_resource_id": str(source["id"]),
|
||||||
|
"source_vmid": vmid,
|
||||||
|
"node": target,
|
||||||
|
"vmid": int(newid),
|
||||||
|
"name": values.get("name"),
|
||||||
|
"description": values.get("description"),
|
||||||
|
"full": bool(values.get("full", False)),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def migrate_preconditions(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
values = _values(inputs)
|
||||||
|
await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
|
||||||
|
target = values.get("target")
|
||||||
|
if target in {None, ""}:
|
||||||
|
raise ApiError(400, "parameter 'target' is required")
|
||||||
|
target = str(target)
|
||||||
|
exists = await _database(request).pool.fetchval(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)", target
|
||||||
|
)
|
||||||
|
if not exists:
|
||||||
|
raise ApiError(404, "target node does not exist")
|
||||||
|
return {"local_disks": [], "local_resources": [], "running": False}
|
||||||
|
|
||||||
|
async def migrate(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
values = _values(inputs)
|
||||||
|
node, vmid = str(values["node"]), str(values["vmid"])
|
||||||
|
target = values.get("target")
|
||||||
|
if target in {None, ""}:
|
||||||
|
raise ApiError(400, "parameter 'target' is required")
|
||||||
|
target = str(target)
|
||||||
|
resource = await _qemu_resource(request, node, vmid)
|
||||||
|
if target == node:
|
||||||
|
raise ApiError(400, "target node is the same as source node")
|
||||||
|
await migrate_preconditions(request, inputs)
|
||||||
|
return await _create_task(
|
||||||
|
request,
|
||||||
|
node=node,
|
||||||
|
vmid=vmid,
|
||||||
|
task_type="qemu-migrate",
|
||||||
|
payload={
|
||||||
|
"resource_id": str(resource["id"]),
|
||||||
|
"node": node,
|
||||||
|
"target": target,
|
||||||
|
"vmid": vmid,
|
||||||
|
"online": bool(values.get("online", False)),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def remote_migrate(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
values = _values(inputs)
|
||||||
|
target_endpoint = str(values.get("target-endpoint") or values.get("target_endpoint") or "")
|
||||||
|
target = str(values.get("target") or "")
|
||||||
|
if not target_endpoint:
|
||||||
|
raise ApiError(400, "parameter target-endpoint is required")
|
||||||
|
if not target:
|
||||||
|
raise ApiError(400, "parameter target is required")
|
||||||
|
node, vmid = str(values["node"]), str(values["vmid"])
|
||||||
|
resource = await _qemu_resource(request, node, vmid)
|
||||||
|
if target == node:
|
||||||
|
raise ApiError(400, "target node is the same as source node")
|
||||||
|
if not await _database(request).pool.fetchval(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM nodes WHERE name=$1)", target
|
||||||
|
):
|
||||||
|
raise ApiError(404, "target node does not exist")
|
||||||
|
return await _create_task(
|
||||||
|
request,
|
||||||
|
node=node,
|
||||||
|
vmid=vmid,
|
||||||
|
task_type="qemu-remote-migrate",
|
||||||
|
payload={
|
||||||
|
"resource_id": str(resource["id"]),
|
||||||
|
"node": node,
|
||||||
|
"target": target,
|
||||||
|
"vmid": vmid,
|
||||||
|
"target-endpoint": target_endpoint,
|
||||||
|
"online": bool(values.get("online", False)),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def resize(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
values = _values(inputs)
|
||||||
|
node, vmid, disk = str(values["node"]), str(values["vmid"]), str(values["disk"])
|
||||||
|
resource = await _qemu_resource(request, node, vmid)
|
||||||
|
config = _state(resource["config"])
|
||||||
|
if disk not in config:
|
||||||
|
raise ApiError(400, f"disk {disk} does not exist")
|
||||||
|
current = _disk_size_bytes(str(config[disk]))
|
||||||
|
size = _resize_bytes(str(values["size"]), current)
|
||||||
|
config[disk] = _replace_disk_size(str(config[disk]), size)
|
||||||
|
status = await _database(request).pool.execute(
|
||||||
|
"""UPDATE virtual_machines SET config=$2::jsonb
|
||||||
|
WHERE resource_id=$1""",
|
||||||
|
resource["id"],
|
||||||
|
json.dumps(config, sort_keys=True),
|
||||||
|
)
|
||||||
|
if status != "UPDATE 1":
|
||||||
|
raise ApiError(409, "configuration changed concurrently")
|
||||||
|
await _database(request).pool.execute(
|
||||||
|
"""UPDATE resources SET state=state || $2::jsonb,version=version+1,
|
||||||
|
updated_at=now() WHERE id=$1""",
|
||||||
|
resource["id"],
|
||||||
|
json.dumps({disk: config[disk]}, sort_keys=True),
|
||||||
|
)
|
||||||
|
await _database(request).pool.execute(
|
||||||
|
"""INSERT INTO vm_disks(id,resource_id,device,storage_id,size_bytes)
|
||||||
|
VALUES(gen_random_uuid(),$1,$2,$3,$4)
|
||||||
|
ON CONFLICT(resource_id,device) DO UPDATE SET size_bytes=EXCLUDED.size_bytes""",
|
||||||
|
resource["id"],
|
||||||
|
disk,
|
||||||
|
str(config[disk]).split(":", 1)[0],
|
||||||
|
size,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def move_disk(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
values = _values(inputs)
|
||||||
|
node, vmid, disk = str(values["node"]), str(values["vmid"]), str(values["disk"])
|
||||||
|
resource = await _qemu_resource(request, node, vmid)
|
||||||
|
if disk not in _state(resource["config"]):
|
||||||
|
raise ApiError(400, f"disk {disk} does not exist")
|
||||||
|
return await _create_task(
|
||||||
|
request,
|
||||||
|
node=node,
|
||||||
|
vmid=vmid,
|
||||||
|
task_type="qemu-move-disk",
|
||||||
|
payload={
|
||||||
|
"resource_id": str(resource["id"]),
|
||||||
|
"disk": disk,
|
||||||
|
"storage": str(values.get("storage") or "local-lvm"),
|
||||||
|
"target_vmid": int(values.get("target-vmid") or vmid),
|
||||||
|
"target_disk": str(values.get("target-disk") or disk),
|
||||||
|
"delete": bool(values.get("delete", True)),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def pending(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
values = _values(inputs)
|
||||||
|
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
|
||||||
|
state = _state(resource["state"])
|
||||||
|
config = _state(resource["config"])
|
||||||
|
changes = cast(Mapping[str, Any], state.get("pending", {}))
|
||||||
|
return [
|
||||||
|
{"key": key, "value": str(config.get(key, "")), "pending": str(value)}
|
||||||
|
for key, value in sorted(changes.items())
|
||||||
|
]
|
||||||
|
|
||||||
|
async def agent_result(
|
||||||
|
command: str, request: Request, inputs: dict[str, Any]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
resource = await _agent_resource(request, _values(inputs))
|
||||||
|
config = _state(resource["config"])
|
||||||
|
vmid = str(_values(inputs)["vmid"])
|
||||||
|
results: dict[str, Any] = {
|
||||||
|
"info": {
|
||||||
|
"version": "9.2.0-simulator",
|
||||||
|
"supported_commands": [
|
||||||
|
{"name": name, "enabled": True, "success-response": True}
|
||||||
|
for name in ("guest-ping", "guest-info", "guest-get-osinfo")
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"get-osinfo": {
|
||||||
|
"name": str(config.get("ostype", "linux")),
|
||||||
|
"pretty-name": "Proxmox Simulator Guest",
|
||||||
|
"version": "1.0",
|
||||||
|
"machine": "x86_64",
|
||||||
|
},
|
||||||
|
"get-host-name": {"host-name": str(config.get("name", f"vm-{vmid}"))},
|
||||||
|
"network-get-interfaces": [
|
||||||
|
{
|
||||||
|
"name": "eth0",
|
||||||
|
"hardware-address": "02:00:00:00:00:01",
|
||||||
|
"ip-addresses": [
|
||||||
|
{"ip-address": "192.0.2.10", "ip-address-type": "ipv4", "prefix": 24}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ping": {},
|
||||||
|
}
|
||||||
|
if command == "get-time":
|
||||||
|
seconds = int(
|
||||||
|
await _database(request).pool.fetchval("SELECT extract(epoch from now())::bigint")
|
||||||
|
)
|
||||||
|
return {"result": {"seconds": seconds, "nanoseconds": 0}}
|
||||||
|
return {"result": results[command]}
|
||||||
|
|
||||||
|
async def agent_info(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await agent_result("info", request, inputs)
|
||||||
|
|
||||||
|
async def agent_osinfo(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await agent_result("get-osinfo", request, inputs)
|
||||||
|
|
||||||
|
async def agent_hostname(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await agent_result("get-host-name", request, inputs)
|
||||||
|
|
||||||
|
async def agent_network(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await agent_result("network-get-interfaces", request, inputs)
|
||||||
|
|
||||||
|
async def agent_time(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await agent_result("get-time", request, inputs)
|
||||||
|
|
||||||
|
async def agent_ping(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await agent_result("ping", request, inputs)
|
||||||
|
|
||||||
|
async def task_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
tasks = await TaskRepository(_database(request).pool).list_for_node(
|
||||||
|
str(_values(inputs)["node"])
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{"upid": task.upid, "status": task.status, "type": task.task_type} for task in tasks
|
||||||
|
]
|
||||||
|
|
||||||
|
async def task_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
task = await TaskRepository(_database(request).pool).get_by_upid(
|
||||||
|
str(_values(inputs)["upid"])
|
||||||
|
)
|
||||||
|
if task is None:
|
||||||
|
raise ApiError(404, "task does not exist")
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"upid": task.upid,
|
||||||
|
"status": "stopped" if task.status in {"success", "error", "cancelled"} else "running",
|
||||||
|
"progress": task.progress,
|
||||||
|
}
|
||||||
|
if task.status in {"success", "error", "cancelled"}:
|
||||||
|
result["exitstatus"] = "OK" if task.status == "success" else task.status.upper()
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def task_log(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
repository = TaskRepository(_database(request).pool)
|
||||||
|
task = await repository.get_by_upid(str(_values(inputs)["upid"]))
|
||||||
|
if task is None:
|
||||||
|
raise ApiError(404, "task does not exist")
|
||||||
|
return [
|
||||||
|
{"n": index + 1, "t": message}
|
||||||
|
for index, message in enumerate(await repository.logs(task.id))
|
||||||
|
]
|
||||||
|
|
||||||
|
async def qemu_feature(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = _values(inputs)
|
||||||
|
await _qemu_resource(request, str(payload["node"]), str(payload["vmid"]))
|
||||||
|
return {
|
||||||
|
"hasFeature": {
|
||||||
|
"snapshot": 1,
|
||||||
|
"clone": 1,
|
||||||
|
"copy": 1,
|
||||||
|
"template": 1,
|
||||||
|
"move_disk": 1,
|
||||||
|
"agent": 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async def qemu_template(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = _values(inputs)
|
||||||
|
node, vmid = str(payload["node"]), str(payload["vmid"])
|
||||||
|
resource = await _qemu_resource(request, node, vmid)
|
||||||
|
state = _state(resource["state"])
|
||||||
|
if state.get("status") != "stopped":
|
||||||
|
raise ApiError(409, "virtual machine must be stopped to convert to template")
|
||||||
|
await _database(request).pool.execute(
|
||||||
|
"UPDATE virtual_machines SET template=true WHERE resource_id=$1",
|
||||||
|
resource["id"],
|
||||||
|
)
|
||||||
|
state["template"] = True
|
||||||
|
await _database(request).pool.execute(
|
||||||
|
"UPDATE resources SET state=$2::jsonb WHERE id=$1",
|
||||||
|
resource["id"],
|
||||||
|
json.dumps(state, sort_keys=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def qemu_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
payload = _values(inputs)
|
||||||
|
node, vmid = str(payload["node"]), str(payload["vmid"])
|
||||||
|
await require_node(request, node)
|
||||||
|
await _qemu_resource(request, node, vmid)
|
||||||
|
return subdirs(
|
||||||
|
"agent",
|
||||||
|
"clone",
|
||||||
|
"config",
|
||||||
|
"feature",
|
||||||
|
"firewall",
|
||||||
|
"migrate",
|
||||||
|
"move_disk",
|
||||||
|
"pending",
|
||||||
|
"resize",
|
||||||
|
"snapshot",
|
||||||
|
"status",
|
||||||
|
"template",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def task_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
payload = _values(inputs)
|
||||||
|
node, upid = str(payload["node"]), str(payload["upid"])
|
||||||
|
await require_node(request, node)
|
||||||
|
task = await TaskRepository(_database(request).pool).get_by_upid(upid)
|
||||||
|
if task is None:
|
||||||
|
raise ApiError(404, "task does not exist")
|
||||||
|
return subdirs("log", "status")
|
||||||
|
|
||||||
|
async def task_delete(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = _values(inputs)
|
||||||
|
upid = str(payload["upid"])
|
||||||
|
repository = TaskRepository(_database(request).pool)
|
||||||
|
task = await repository.get_by_upid(upid)
|
||||||
|
if task is None:
|
||||||
|
raise ApiError(404, "task does not exist")
|
||||||
|
if task.status in {"success", "error", "cancelled"}:
|
||||||
|
return
|
||||||
|
await repository.request_cancel(task.id)
|
||||||
|
|
||||||
|
registry.register("/nodes/{node}/qemu", "GET", qemu_list)
|
||||||
|
registry.register("/nodes/{node}/qemu", "POST", create)
|
||||||
|
registry.register("/nodes/{node}/qemu/{vmid}", "GET", qemu_index)
|
||||||
|
registry.register("/nodes/{node}/qemu/{vmid}", "DELETE", delete)
|
||||||
|
registry.register("/nodes/{node}/qemu/{vmid}/config", "GET", qemu_config)
|
||||||
|
registry.register("/nodes/{node}/qemu/{vmid}/config", "POST", update_async)
|
||||||
|
registry.register("/nodes/{node}/qemu/{vmid}/config", "PUT", update_sync)
|
||||||
|
registry.register("/nodes/{node}/qemu/{vmid}/status", "GET", qemu_status_index)
|
||||||
|
registry.register("/nodes/{node}/qemu/{vmid}/status/current", "GET", qemu_status_current)
|
||||||
|
registry.register("/nodes/{node}/qemu/{vmid}/status/start", "POST", start)
|
||||||
|
registry.register("/nodes/{node}/qemu/{vmid}/status/stop", "POST", stop)
|
||||||
|
registry.register("/nodes/{node}/qemu/{vmid}/status/shutdown", "POST", shutdown)
|
||||||
|
registry.register("/nodes/{node}/qemu/{vmid}/status/reboot", "POST", reboot)
|
||||||
|
registry.register("/nodes/{node}/qemu/{vmid}/status/reset", "POST", reset)
|
||||||
|
registry.register("/nodes/{node}/qemu/{vmid}/status/suspend", "POST", suspend)
|
||||||
|
registry.register("/nodes/{node}/qemu/{vmid}/status/resume", "POST", resume)
|
||||||
|
registry.register("/nodes/{node}/qemu/{vmid}/snapshot", "GET", snapshot_list)
|
||||||
|
registry.register("/nodes/{node}/qemu/{vmid}/snapshot", "POST", snapshot_create)
|
||||||
|
registry.register("/nodes/{node}/qemu/{vmid}/snapshot/{snapname}", "GET", snapshot_get)
|
||||||
|
registry.register("/nodes/{node}/qemu/{vmid}/snapshot/{snapname}", "DELETE", snapshot_delete)
|
||||||
|
registry.register(
|
||||||
|
"/nodes/{node}/qemu/{vmid}/snapshot/{snapname}/config", "GET", snapshot_config
|
||||||
|
)
|
||||||
|
registry.register(
|
||||||
|
"/nodes/{node}/qemu/{vmid}/snapshot/{snapname}/config", "PUT", snapshot_update
|
||||||
|
)
|
||||||
|
registry.register(
|
||||||
|
"/nodes/{node}/qemu/{vmid}/snapshot/{snapname}/rollback", "POST", snapshot_rollback
|
||||||
|
)
|
||||||
|
registry.register("/nodes/{node}/qemu/{vmid}/clone", "POST", clone)
|
||||||
|
registry.register("/nodes/{node}/qemu/{vmid}/migrate", "GET", migrate_preconditions)
|
||||||
|
registry.register("/nodes/{node}/qemu/{vmid}/migrate", "POST", migrate)
|
||||||
|
registry.register("/nodes/{node}/qemu/{vmid}/remote_migrate", "POST", remote_migrate)
|
||||||
|
registry.register("/nodes/{node}/qemu/{vmid}/resize", "PUT", resize)
|
||||||
|
registry.register("/nodes/{node}/qemu/{vmid}/move_disk", "POST", move_disk)
|
||||||
|
registry.register("/nodes/{node}/qemu/{vmid}/pending", "GET", pending)
|
||||||
|
registry.register("/nodes/{node}/qemu/{vmid}/agent/info", "GET", agent_info)
|
||||||
|
registry.register("/nodes/{node}/qemu/{vmid}/agent/get-osinfo", "GET", agent_osinfo)
|
||||||
|
registry.register("/nodes/{node}/qemu/{vmid}/agent/get-host-name", "GET", agent_hostname)
|
||||||
|
registry.register(
|
||||||
|
"/nodes/{node}/qemu/{vmid}/agent/network-get-interfaces", "GET", agent_network
|
||||||
|
)
|
||||||
|
registry.register("/nodes/{node}/qemu/{vmid}/agent/get-time", "GET", agent_time)
|
||||||
|
registry.register("/nodes/{node}/qemu/{vmid}/agent/ping", "POST", agent_ping)
|
||||||
|
registry.register("/nodes/{node}/qemu/{vmid}/feature", "GET", qemu_feature)
|
||||||
|
registry.register("/nodes/{node}/qemu/{vmid}/template", "POST", qemu_template)
|
||||||
|
registry.register("/nodes/{node}/tasks", "GET", task_list)
|
||||||
|
registry.register("/nodes/{node}/tasks/{upid}", "GET", task_index)
|
||||||
|
registry.register("/nodes/{node}/tasks/{upid}", "DELETE", task_delete)
|
||||||
|
registry.register("/nodes/{node}/tasks/{upid}/status", "GET", task_status)
|
||||||
|
registry.register("/nodes/{node}/tasks/{upid}/log", "GET", task_log)
|
||||||
|
from app.handlers.qemu_extra import register_qemu_extra_handlers
|
||||||
|
|
||||||
|
register_qemu_extra_handlers(registry)
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_task(
|
||||||
|
request: Request,
|
||||||
|
*,
|
||||||
|
node: str,
|
||||||
|
vmid: str,
|
||||||
|
task_type: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> str:
|
||||||
|
database = _database(request)
|
||||||
|
worker_type = {
|
||||||
|
"qemu-create": "qmcreate",
|
||||||
|
"qemu-delete": "qmdestroy",
|
||||||
|
"qemu-update": "qmconfig",
|
||||||
|
"qemu-snapshot-create": "qmsnapshot",
|
||||||
|
"qemu-snapshot-delete": "qmdelsnapshot",
|
||||||
|
"qemu-snapshot-rollback": "qmrollback",
|
||||||
|
"qemu-clone": "qmclone",
|
||||||
|
"qemu-migrate": "qmigrate",
|
||||||
|
"qemu-remote-migrate": "qmremote",
|
||||||
|
"qemu-move-disk": "qmmove",
|
||||||
|
}[task_type]
|
||||||
|
upid = str(Upid.allocate(node, worker_type, vmid, str(request.state.principal)))
|
||||||
|
try:
|
||||||
|
task = await TaskRepository(database.pool).create(
|
||||||
|
upid=upid,
|
||||||
|
task_type=task_type,
|
||||||
|
payload=payload,
|
||||||
|
resource_key=f"qemu:{vmid}",
|
||||||
|
idempotency_key=request.headers.get("Idempotency-Key"),
|
||||||
|
)
|
||||||
|
except ConflictError as error:
|
||||||
|
raise ApiError(409, str(error)) from error
|
||||||
|
return task.upid
|
||||||
|
|
||||||
|
|
||||||
|
async def _qemu_resource(request: Request, node: str, vmid: str) -> Any:
|
||||||
|
row = await _database(request).pool.fetchrow(
|
||||||
|
"""SELECT r.id, r.state, v.config FROM resources r
|
||||||
|
JOIN nodes n ON n.id=r.node_id
|
||||||
|
JOIN virtual_machines v ON v.resource_id=r.id
|
||||||
|
WHERE n.name=$1 AND r.kind='qemu' AND r.external_id=$2""",
|
||||||
|
node,
|
||||||
|
vmid,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "virtual machine does not exist")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
async def _snapshot(request: Request, values: dict[str, Any]) -> Any:
|
||||||
|
row = await _database(request).pool.fetchrow(
|
||||||
|
"""SELECT s.* FROM snapshots s
|
||||||
|
JOIN resources r ON r.id=s.resource_id JOIN nodes n ON n.id=r.node_id
|
||||||
|
WHERE n.name=$1 AND r.kind='qemu' AND r.external_id=$2 AND s.name=$3""",
|
||||||
|
str(values["node"]),
|
||||||
|
str(values["vmid"]),
|
||||||
|
str(values["snapname"]),
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "snapshot does not exist")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
async def _agent_resource(request: Request, values: dict[str, Any]) -> Any:
|
||||||
|
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
|
||||||
|
config = _state(resource["config"])
|
||||||
|
state = _state(resource["state"])
|
||||||
|
if str(config.get("agent", "0")).split(",", 1)[0].lower() not in {"1", "true", "yes"}:
|
||||||
|
raise ApiError(409, "QEMU guest agent is not enabled")
|
||||||
|
if state.get("status") != "running":
|
||||||
|
raise ApiError(409, "QEMU guest agent is not running")
|
||||||
|
return resource
|
||||||
|
|
||||||
|
|
||||||
|
_SIZE_RE = re.compile(r"^(?P<value>\d+)(?P<unit>[KMGT]?)$", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _size_bytes(value: str) -> int:
|
||||||
|
match = _SIZE_RE.fullmatch(value.strip())
|
||||||
|
if match is None:
|
||||||
|
raise ApiError(400, f"invalid disk size: {value}")
|
||||||
|
units = {"": 1, "K": 2**10, "M": 2**20, "G": 2**30, "T": 2**40}
|
||||||
|
return int(match.group("value")) * units[match.group("unit").upper()]
|
||||||
|
|
||||||
|
|
||||||
|
def _disk_size_bytes(value: str) -> int:
|
||||||
|
for part in value.split(","):
|
||||||
|
if part.startswith("size="):
|
||||||
|
return _size_bytes(part.removeprefix("size="))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _resize_bytes(value: str, current: int) -> int:
|
||||||
|
if value.startswith("+"):
|
||||||
|
return current + _size_bytes(value[1:])
|
||||||
|
result = _size_bytes(value)
|
||||||
|
if result < current:
|
||||||
|
raise ApiError(400, "shrinking disks is not supported")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_disk_size(value: str, size: int) -> str:
|
||||||
|
parts = [part for part in value.split(",") if not part.startswith("size=")]
|
||||||
|
parts.append(f"size={size // 2**30}G" if size % 2**30 == 0 else f"size={size}")
|
||||||
|
return ",".join(parts)
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
"""Additional QEMU guest/agent/console endpoints with durable guest state."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from app.api.errors import ApiError
|
||||||
|
from app.api.registry import HandlerRegistry
|
||||||
|
from app.config import Settings
|
||||||
|
from app.handlers.qemu import _agent_resource, _database, _qemu_resource, _state, _values
|
||||||
|
from app.security.auth import issue_ticket
|
||||||
|
|
||||||
|
|
||||||
|
def _settings(request: Request) -> Settings:
|
||||||
|
return cast(Settings, request.app.state.settings)
|
||||||
|
|
||||||
|
|
||||||
|
async def _save_guest_state(request: Request, resource_id: Any, state: dict[str, Any]) -> None:
|
||||||
|
await _database(request).pool.execute(
|
||||||
|
"UPDATE resources SET state=$2::jsonb, version=version+1, updated_at=now() WHERE id=$1",
|
||||||
|
resource_id,
|
||||||
|
json.dumps(state, sort_keys=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _save_guest_config(request: Request, resource_id: Any, config: dict[str, Any]) -> None:
|
||||||
|
await _database(request).pool.execute(
|
||||||
|
"UPDATE virtual_machines SET config=$2::jsonb WHERE resource_id=$1",
|
||||||
|
resource_id,
|
||||||
|
json.dumps(config, sort_keys=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register_qemu_extra_handlers(registry: HandlerRegistry) -> None:
|
||||||
|
async def agent_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
await _agent_resource(request, _values(inputs))
|
||||||
|
return [
|
||||||
|
{"name": name}
|
||||||
|
for name in (
|
||||||
|
"exec",
|
||||||
|
"exec-status",
|
||||||
|
"file-read",
|
||||||
|
"file-write",
|
||||||
|
"fsfreeze-freeze",
|
||||||
|
"fsfreeze-status",
|
||||||
|
"fsfreeze-thaw",
|
||||||
|
"fstrim",
|
||||||
|
"get-fsinfo",
|
||||||
|
"get-memory-block-info",
|
||||||
|
"get-memory-blocks",
|
||||||
|
"get-timezone",
|
||||||
|
"get-users",
|
||||||
|
"get-vcpus",
|
||||||
|
"info",
|
||||||
|
"ping",
|
||||||
|
"set-user-password",
|
||||||
|
"shutdown",
|
||||||
|
"suspend-disk",
|
||||||
|
"suspend-hybrid",
|
||||||
|
"suspend-ram",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
async def agent_post(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = _values(inputs)
|
||||||
|
command = str(payload.get("command") or "ping")
|
||||||
|
resource = await _agent_resource(request, payload)
|
||||||
|
state = _state(resource["state"])
|
||||||
|
agent = state.setdefault("agent", {})
|
||||||
|
agent["last_command"] = command
|
||||||
|
await _save_guest_state(request, resource["id"], state)
|
||||||
|
return {"result": {"command": command, "ok": 1}}
|
||||||
|
|
||||||
|
async def _agent_blob(command: str, request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
resource = await _agent_resource(request, _values(inputs))
|
||||||
|
state = _state(resource["state"])
|
||||||
|
agent = state.setdefault("agent", {})
|
||||||
|
blobs = agent.setdefault("results", {})
|
||||||
|
defaults: dict[str, Any] = {
|
||||||
|
"get-users": [{"user": "root", "login-time": 0}],
|
||||||
|
"get-fsinfo": [{"name": "/", "type": "ext4", "total-bytes": 32 * 1024**3}],
|
||||||
|
"get-memory-block-info": {"size": 1024**3},
|
||||||
|
"get-memory-blocks": [{"start": 0, "size": 1024**3}],
|
||||||
|
"get-timezone": {"zone": "UTC", "offset": 0},
|
||||||
|
"get-vcpus": [{"online": True, "can-offline": False}],
|
||||||
|
"fsfreeze-status": "thawed",
|
||||||
|
}
|
||||||
|
if command not in blobs:
|
||||||
|
blobs[command] = defaults.get(command, {})
|
||||||
|
await _save_guest_state(request, resource["id"], state)
|
||||||
|
return {"result": blobs[command]}
|
||||||
|
|
||||||
|
async def agent_users(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await _agent_blob("get-users", request, inputs)
|
||||||
|
|
||||||
|
async def agent_fsinfo(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await _agent_blob("get-fsinfo", request, inputs)
|
||||||
|
|
||||||
|
async def agent_memory_block_info(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await _agent_blob("get-memory-block-info", request, inputs)
|
||||||
|
|
||||||
|
async def agent_memory_blocks(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await _agent_blob("get-memory-blocks", request, inputs)
|
||||||
|
|
||||||
|
async def agent_timezone(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await _agent_blob("get-timezone", request, inputs)
|
||||||
|
|
||||||
|
async def agent_vcpus(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await _agent_blob("get-vcpus", request, inputs)
|
||||||
|
|
||||||
|
async def agent_exec(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = _values(inputs)
|
||||||
|
resource = await _agent_resource(request, payload)
|
||||||
|
state = _state(resource["state"])
|
||||||
|
agent = state.setdefault("agent", {})
|
||||||
|
execs = agent.setdefault("exec", {})
|
||||||
|
pid = int(agent.get("next_pid", 1000)) + 1
|
||||||
|
agent["next_pid"] = pid
|
||||||
|
execs[str(pid)] = {
|
||||||
|
"exited": 1,
|
||||||
|
"exitcode": 0,
|
||||||
|
"out-data": "",
|
||||||
|
"err-data": "",
|
||||||
|
"command": payload.get("command"),
|
||||||
|
}
|
||||||
|
await _save_guest_state(request, resource["id"], state)
|
||||||
|
return {"pid": pid}
|
||||||
|
|
||||||
|
async def agent_exec_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = _values(inputs)
|
||||||
|
resource = await _agent_resource(request, payload)
|
||||||
|
pid = str(payload.get("pid") or "")
|
||||||
|
state = _state(resource["state"])
|
||||||
|
result = state.get("agent", {}).get("exec", {}).get(pid)
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
raise ApiError(404, "exec process does not exist")
|
||||||
|
return {"result": result}
|
||||||
|
|
||||||
|
async def agent_file_read(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = _values(inputs)
|
||||||
|
resource = await _agent_resource(request, payload)
|
||||||
|
path = str(payload.get("file") or payload.get("path") or "/etc/hostname")
|
||||||
|
state = _state(resource["state"])
|
||||||
|
files = state.setdefault("agent", {}).setdefault("files", {})
|
||||||
|
if path not in files:
|
||||||
|
files[path] = f"simulated:{path}\n"
|
||||||
|
await _save_guest_state(request, resource["id"], state)
|
||||||
|
content = str(files[path])
|
||||||
|
return {"result": {"content": content, "truncated": True}}
|
||||||
|
|
||||||
|
async def agent_file_write(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = _values(inputs)
|
||||||
|
resource = await _agent_resource(request, payload)
|
||||||
|
default_path = "guest-agent-out"
|
||||||
|
path = str(payload.get("file") or payload.get("path") or default_path)
|
||||||
|
content = str(payload.get("content") or "")
|
||||||
|
state = _state(resource["state"])
|
||||||
|
files = state.setdefault("agent", {}).setdefault("files", {})
|
||||||
|
files[path] = content
|
||||||
|
await _save_guest_state(request, resource["id"], state)
|
||||||
|
return {"result": None}
|
||||||
|
|
||||||
|
async def agent_fsfreeze(
|
||||||
|
request: Request, inputs: dict[str, Any], status: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
resource = await _agent_resource(request, _values(inputs))
|
||||||
|
state = _state(resource["state"])
|
||||||
|
agent = state.setdefault("agent", {})
|
||||||
|
agent["fsfreeze"] = status
|
||||||
|
agent.setdefault("results", {})["fsfreeze-status"] = status
|
||||||
|
await _save_guest_state(request, resource["id"], state)
|
||||||
|
return {"result": status}
|
||||||
|
|
||||||
|
async def agent_fsfreeze_freeze(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await agent_fsfreeze(request, inputs, "frozen")
|
||||||
|
|
||||||
|
async def agent_fsfreeze_thaw(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await agent_fsfreeze(request, inputs, "thawed")
|
||||||
|
|
||||||
|
async def agent_fsfreeze_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await _agent_blob("fsfreeze-status", request, inputs)
|
||||||
|
|
||||||
|
async def agent_fstrim(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
resource = await _agent_resource(request, _values(inputs))
|
||||||
|
state = _state(resource["state"])
|
||||||
|
state.setdefault("agent", {})["last_fstrim"] = True
|
||||||
|
await _save_guest_state(request, resource["id"], state)
|
||||||
|
return {"result": {"paths": [{"path": "/", "trimmed": 0}]}}
|
||||||
|
|
||||||
|
async def agent_set_password(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = _values(inputs)
|
||||||
|
resource = await _agent_resource(request, payload)
|
||||||
|
username = str(payload.get("username") or "root")
|
||||||
|
state = _state(resource["state"])
|
||||||
|
passwords = state.setdefault("agent", {}).setdefault("passwords", {})
|
||||||
|
passwords[username] = True # store only presence, not secret
|
||||||
|
await _save_guest_state(request, resource["id"], state)
|
||||||
|
return {"result": None}
|
||||||
|
|
||||||
|
async def agent_shutdown(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
resource = await _agent_resource(request, _values(inputs))
|
||||||
|
state = _state(resource["state"])
|
||||||
|
state["status"] = "stopped"
|
||||||
|
await _save_guest_state(request, resource["id"], state)
|
||||||
|
return {"result": None}
|
||||||
|
|
||||||
|
async def agent_suspend(request: Request, inputs: dict[str, Any], mode: str) -> dict[str, Any]:
|
||||||
|
resource = await _agent_resource(request, _values(inputs))
|
||||||
|
state = _state(resource["state"])
|
||||||
|
state["status"] = "paused"
|
||||||
|
state.setdefault("agent", {})["suspend"] = mode
|
||||||
|
await _save_guest_state(request, resource["id"], state)
|
||||||
|
return {"result": None}
|
||||||
|
|
||||||
|
async def agent_suspend_disk(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await agent_suspend(request, inputs, "disk")
|
||||||
|
|
||||||
|
async def agent_suspend_ram(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await agent_suspend(request, inputs, "ram")
|
||||||
|
|
||||||
|
async def agent_suspend_hybrid(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await agent_suspend(request, inputs, "hybrid")
|
||||||
|
|
||||||
|
async def cloudinit_get(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
values = _values(inputs)
|
||||||
|
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
|
||||||
|
config = _state(resource["config"])
|
||||||
|
state = _state(resource["state"])
|
||||||
|
pending = cast(Mapping[str, Any], state.get("pending", {}))
|
||||||
|
keys = sorted(
|
||||||
|
{
|
||||||
|
key
|
||||||
|
for key in set(config) | set(pending)
|
||||||
|
if str(key).startswith(("ci", "ipconfig", "sshkeys", "nameserver", "searchdomain"))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"key": key,
|
||||||
|
"value": str(config.get(key, "")),
|
||||||
|
"pending": str(pending[key]) if key in pending else None,
|
||||||
|
}
|
||||||
|
for key in keys
|
||||||
|
]
|
||||||
|
|
||||||
|
async def cloudinit_update(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
values = _values(inputs)
|
||||||
|
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
|
||||||
|
state = _state(resource["state"])
|
||||||
|
state["cloudinit_generation"] = int(state.get("cloudinit_generation") or 0) + 1
|
||||||
|
await _save_guest_state(request, resource["id"], state)
|
||||||
|
|
||||||
|
async def cloudinit_dump(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
values = _values(inputs)
|
||||||
|
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
|
||||||
|
config = _state(resource["config"])
|
||||||
|
return (
|
||||||
|
f"#cloud-config\nhostname: {config.get('name', values['vmid'])}\n"
|
||||||
|
f"manage_etc_hosts: true\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def rrd(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
values = _values(inputs)
|
||||||
|
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
|
||||||
|
state = _state(resource["state"])
|
||||||
|
rrd_state = state.setdefault("rrd", {"filename": f"pve-vm-{values['vmid']}.rrd"})
|
||||||
|
await _save_guest_state(request, resource["id"], state)
|
||||||
|
return dict(rrd_state)
|
||||||
|
|
||||||
|
async def rrddata(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
values = _values(inputs)
|
||||||
|
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
|
||||||
|
state = _state(resource["state"])
|
||||||
|
series = state.setdefault(
|
||||||
|
"rrddata",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"time": 1_700_000_000,
|
||||||
|
"cpu": 0.05,
|
||||||
|
"mem": 256 * 1024 * 1024,
|
||||||
|
"netin": 0,
|
||||||
|
"netout": 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"time": 1_700_000_060,
|
||||||
|
"cpu": 0.08,
|
||||||
|
"mem": 260 * 1024 * 1024,
|
||||||
|
"netin": 100,
|
||||||
|
"netout": 80,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
await _save_guest_state(request, resource["id"], state)
|
||||||
|
return list(series)
|
||||||
|
|
||||||
|
async def monitor(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
values = _values(inputs)
|
||||||
|
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
|
||||||
|
command = str(values.get("command") or "info status")
|
||||||
|
state = _state(resource["state"])
|
||||||
|
history = state.setdefault("monitor", [])
|
||||||
|
if not isinstance(history, list):
|
||||||
|
history = state["monitor"] = []
|
||||||
|
output = f"OK {command}"
|
||||||
|
history.append({"command": command, "output": output})
|
||||||
|
await _save_guest_state(request, resource["id"], state)
|
||||||
|
return output
|
||||||
|
|
||||||
|
async def sendkey(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
values = _values(inputs)
|
||||||
|
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
|
||||||
|
key = str(values.get("key") or "")
|
||||||
|
if not key:
|
||||||
|
raise ApiError(400, "parameter verification failed - 'key' missing")
|
||||||
|
state = _state(resource["state"])
|
||||||
|
keys = state.setdefault("sendkey", [])
|
||||||
|
if not isinstance(keys, list):
|
||||||
|
keys = state["sendkey"] = []
|
||||||
|
keys.append(key)
|
||||||
|
await _save_guest_state(request, resource["id"], state)
|
||||||
|
|
||||||
|
async def unlink(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
values = _values(inputs)
|
||||||
|
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
|
||||||
|
idlist = [
|
||||||
|
item.strip()
|
||||||
|
for item in str(values.get("idlist") or values.get("ids") or "").split(",")
|
||||||
|
if item.strip()
|
||||||
|
]
|
||||||
|
if not idlist:
|
||||||
|
raise ApiError(400, "parameter verification failed - 'idlist' missing")
|
||||||
|
config = _state(resource["config"])
|
||||||
|
for disk in idlist:
|
||||||
|
config.pop(disk, None)
|
||||||
|
await _save_guest_config(request, resource["id"], config)
|
||||||
|
state = _state(resource["state"])
|
||||||
|
state["config"] = config
|
||||||
|
await _save_guest_state(request, resource["id"], state)
|
||||||
|
|
||||||
|
async def _console_proxy(request: Request, inputs: dict[str, Any], kind: str) -> dict[str, Any]:
|
||||||
|
values = _values(inputs)
|
||||||
|
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
|
||||||
|
key = _settings(request).ticket_signing_key.get_secret_value().encode()
|
||||||
|
ticket = issue_ticket(str(request.state.principal), key)
|
||||||
|
port = 5900 + int(values["vmid"]) % 1000
|
||||||
|
state = _state(resource["state"])
|
||||||
|
consoles = state.setdefault("consoles", {})
|
||||||
|
payload = {
|
||||||
|
"type": kind,
|
||||||
|
"port": port,
|
||||||
|
"ticket": ticket,
|
||||||
|
"upid": (
|
||||||
|
f"UPID:{values['node']}:{secrets.token_hex(4)}:"
|
||||||
|
f"{kind}:{values['vmid']}:{request.state.principal}:"
|
||||||
|
),
|
||||||
|
"user": str(request.state.principal),
|
||||||
|
"cert": "",
|
||||||
|
}
|
||||||
|
if values.get("generate-password") or values.get("websocket"):
|
||||||
|
payload["password"] = secrets.token_urlsafe(8)
|
||||||
|
consoles[kind] = {k: v for k, v in payload.items() if k != "ticket"}
|
||||||
|
await _save_guest_state(request, resource["id"], state)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
async def vncproxy(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await _console_proxy(request, inputs, "vnc")
|
||||||
|
|
||||||
|
async def spiceproxy(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await _console_proxy(request, inputs, "spice")
|
||||||
|
|
||||||
|
async def termproxy(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await _console_proxy(request, inputs, "term")
|
||||||
|
|
||||||
|
async def mtunnel(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await _console_proxy(request, inputs, "mtunnel")
|
||||||
|
|
||||||
|
async def websocket_ticket(
|
||||||
|
request: Request, inputs: dict[str, Any], kind: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
values = _values(inputs)
|
||||||
|
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
|
||||||
|
state = _state(resource["state"])
|
||||||
|
console = state.get("consoles", {}).get(kind) or {"port": 5900}
|
||||||
|
key = _settings(request).ticket_signing_key.get_secret_value().encode()
|
||||||
|
return {
|
||||||
|
"port": console.get("port", 5900),
|
||||||
|
"ticket": issue_ticket(str(request.state.principal), key),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def vncwebsocket(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await websocket_ticket(request, inputs, "vnc")
|
||||||
|
|
||||||
|
async def mtunnelwebsocket(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return await websocket_ticket(request, inputs, "mtunnel")
|
||||||
|
|
||||||
|
async def dbus_vmstate(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
values = _values(inputs)
|
||||||
|
resource = await _qemu_resource(request, str(values["node"]), str(values["vmid"]))
|
||||||
|
state = _state(resource["state"])
|
||||||
|
state["dbus_vmstate"] = True
|
||||||
|
await _save_guest_state(request, resource["id"], state)
|
||||||
|
return {"result": "OK"}
|
||||||
|
|
||||||
|
base = "/nodes/{node}/qemu/{vmid}"
|
||||||
|
registry.register(f"{base}/agent", "GET", agent_index)
|
||||||
|
registry.register(f"{base}/agent", "POST", agent_post)
|
||||||
|
registry.register(f"{base}/agent/exec", "POST", agent_exec)
|
||||||
|
registry.register(f"{base}/agent/exec-status", "GET", agent_exec_status)
|
||||||
|
registry.register(f"{base}/agent/file-read", "GET", agent_file_read)
|
||||||
|
registry.register(f"{base}/agent/file-write", "POST", agent_file_write)
|
||||||
|
registry.register(f"{base}/agent/fsfreeze-freeze", "POST", agent_fsfreeze_freeze)
|
||||||
|
registry.register(f"{base}/agent/fsfreeze-status", "POST", agent_fsfreeze_status)
|
||||||
|
registry.register(f"{base}/agent/fsfreeze-thaw", "POST", agent_fsfreeze_thaw)
|
||||||
|
registry.register(f"{base}/agent/fstrim", "POST", agent_fstrim)
|
||||||
|
registry.register(f"{base}/agent/get-fsinfo", "GET", agent_fsinfo)
|
||||||
|
registry.register(f"{base}/agent/get-memory-block-info", "GET", agent_memory_block_info)
|
||||||
|
registry.register(f"{base}/agent/get-memory-blocks", "GET", agent_memory_blocks)
|
||||||
|
registry.register(f"{base}/agent/get-timezone", "GET", agent_timezone)
|
||||||
|
registry.register(f"{base}/agent/get-users", "GET", agent_users)
|
||||||
|
registry.register(f"{base}/agent/get-vcpus", "GET", agent_vcpus)
|
||||||
|
registry.register(f"{base}/agent/set-user-password", "POST", agent_set_password)
|
||||||
|
registry.register(f"{base}/agent/shutdown", "POST", agent_shutdown)
|
||||||
|
registry.register(f"{base}/agent/suspend-disk", "POST", agent_suspend_disk)
|
||||||
|
registry.register(f"{base}/agent/suspend-hybrid", "POST", agent_suspend_hybrid)
|
||||||
|
registry.register(f"{base}/agent/suspend-ram", "POST", agent_suspend_ram)
|
||||||
|
registry.register(f"{base}/cloudinit", "GET", cloudinit_get)
|
||||||
|
registry.register(f"{base}/cloudinit", "PUT", cloudinit_update)
|
||||||
|
registry.register(f"{base}/cloudinit/dump", "GET", cloudinit_dump)
|
||||||
|
registry.register(f"{base}/rrd", "GET", rrd)
|
||||||
|
registry.register(f"{base}/rrddata", "GET", rrddata)
|
||||||
|
registry.register(f"{base}/monitor", "POST", monitor)
|
||||||
|
registry.register(f"{base}/sendkey", "PUT", sendkey)
|
||||||
|
registry.register(f"{base}/unlink", "PUT", unlink)
|
||||||
|
registry.register(f"{base}/vncproxy", "POST", vncproxy)
|
||||||
|
registry.register(f"{base}/spiceproxy", "POST", spiceproxy)
|
||||||
|
registry.register(f"{base}/termproxy", "POST", termproxy)
|
||||||
|
registry.register(f"{base}/mtunnel", "POST", mtunnel)
|
||||||
|
registry.register(f"{base}/vncwebsocket", "GET", vncwebsocket)
|
||||||
|
registry.register(f"{base}/mtunnelwebsocket", "GET", mtunnelwebsocket)
|
||||||
|
registry.register(f"{base}/dbus-vmstate", "POST", dbus_vmstate)
|
||||||
+1027
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,576 @@
|
|||||||
|
"""Storage semantic handlers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from app.api.errors import ApiError
|
||||||
|
from app.api.registry import HandlerRegistry
|
||||||
|
from app.handlers.common import database, require_node, state, storage_payload, subdirs, values
|
||||||
|
from app.simulation.seed import CLUSTER_ID, stable_id
|
||||||
|
|
||||||
|
|
||||||
|
def register_storage_handlers(registry: HandlerRegistry) -> None:
|
||||||
|
async def _storage_row(request: Request, node: str | None, storage_id: str) -> Any:
|
||||||
|
row = await database(request).pool.fetchrow(
|
||||||
|
"""SELECT s.storage_id, s.storage_type, s.shared, s.capacity_bytes, s.used_bytes,
|
||||||
|
s.config, n.name AS node_name
|
||||||
|
FROM storages s
|
||||||
|
JOIN resources r ON r.id = s.resource_id
|
||||||
|
JOIN nodes n ON n.id = r.node_id
|
||||||
|
WHERE s.storage_id=$1 AND ($2::text IS NULL OR n.name=$2)""",
|
||||||
|
storage_id,
|
||||||
|
node,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "storage does not exist")
|
||||||
|
return row
|
||||||
|
|
||||||
|
async def storage_ids(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
rows = await database(_request).pool.fetch(
|
||||||
|
"SELECT DISTINCT storage_id FROM storages ORDER BY storage_id"
|
||||||
|
)
|
||||||
|
return [{"storage": str(row["storage_id"])} for row in rows]
|
||||||
|
|
||||||
|
async def storage_create(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = values(inputs)
|
||||||
|
storage_id = str(payload["storage"])
|
||||||
|
storage_type = str(payload.get("type") or "dir")
|
||||||
|
exists = await database(request).pool.fetchval(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM storages WHERE storage_id=$1)",
|
||||||
|
storage_id,
|
||||||
|
)
|
||||||
|
if exists:
|
||||||
|
raise ApiError(409, "storage ID already exists")
|
||||||
|
node = await database(request).pool.fetchrow(
|
||||||
|
"SELECT id, name FROM nodes ORDER BY name LIMIT 1"
|
||||||
|
)
|
||||||
|
if node is None:
|
||||||
|
raise ApiError(503, "no nodes available")
|
||||||
|
resource_id = stable_id(f"storage:{storage_id}")
|
||||||
|
config = {
|
||||||
|
key: value
|
||||||
|
for key, value in payload.items()
|
||||||
|
if key not in {"storage", "type", "nodes", "delete"}
|
||||||
|
}
|
||||||
|
if "content" in payload:
|
||||||
|
config["content"] = [
|
||||||
|
item.strip() for item in str(payload["content"]).split(",") if item.strip()
|
||||||
|
]
|
||||||
|
async with database(request).pool.acquire() as connection:
|
||||||
|
async with connection.transaction():
|
||||||
|
await connection.execute(
|
||||||
|
"""INSERT INTO resources(id, node_id, kind, external_id, state, cluster_id)
|
||||||
|
VALUES($1, $2, 'storage', $3, $4::jsonb, $5)""",
|
||||||
|
resource_id,
|
||||||
|
node["id"],
|
||||||
|
storage_id,
|
||||||
|
json.dumps({**config, "status": "available"}, sort_keys=True),
|
||||||
|
CLUSTER_ID,
|
||||||
|
)
|
||||||
|
await connection.execute(
|
||||||
|
"""INSERT INTO storages(
|
||||||
|
resource_id, cluster_id, storage_id, storage_type, shared, config
|
||||||
|
) VALUES($1, $2, $3, $4, $5, $6::jsonb)""",
|
||||||
|
resource_id,
|
||||||
|
CLUSTER_ID,
|
||||||
|
storage_id,
|
||||||
|
storage_type,
|
||||||
|
bool(payload.get("shared", False)),
|
||||||
|
json.dumps(config, sort_keys=True),
|
||||||
|
)
|
||||||
|
return {"storage": storage_id, "type": storage_type, "config": config}
|
||||||
|
|
||||||
|
async def storage_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
storage_id = str(values(inputs)["storage"])
|
||||||
|
row = await _storage_row(request, None, storage_id)
|
||||||
|
config = state(row["config"])
|
||||||
|
return {
|
||||||
|
"storage": storage_id,
|
||||||
|
"type": str(row["storage_type"]),
|
||||||
|
"shared": int(bool(row["shared"])),
|
||||||
|
**config,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def storage_update(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
storage_id = str(values(inputs)["storage"])
|
||||||
|
row = await database(request).pool.fetchrow(
|
||||||
|
"""SELECT s.resource_id, s.config FROM storages s WHERE s.storage_id=$1""",
|
||||||
|
storage_id,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "storage does not exist")
|
||||||
|
current = state(row["config"])
|
||||||
|
provided = values(inputs)
|
||||||
|
updated = {
|
||||||
|
**current,
|
||||||
|
**{
|
||||||
|
key: value
|
||||||
|
for key, value in provided.items()
|
||||||
|
if key not in {"storage", "delete", "digest"}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"UPDATE storages SET config=$2::jsonb WHERE storage_id=$1",
|
||||||
|
storage_id,
|
||||||
|
json.dumps(updated, sort_keys=True),
|
||||||
|
)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
async def storage_delete(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
storage_id = str(values(inputs)["storage"])
|
||||||
|
status = await database(request).pool.execute(
|
||||||
|
"""DELETE FROM resources r USING storages s
|
||||||
|
WHERE s.resource_id=r.id AND s.storage_id=$1""",
|
||||||
|
storage_id,
|
||||||
|
)
|
||||||
|
if status != "DELETE 1":
|
||||||
|
raise ApiError(404, "storage does not exist")
|
||||||
|
|
||||||
|
async def node_storage_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
await require_node(request, node)
|
||||||
|
rows = await database(request).pool.fetch(
|
||||||
|
"""SELECT s.storage_id, s.storage_type, s.shared,
|
||||||
|
s.capacity_bytes, s.used_bytes, s.config
|
||||||
|
FROM storages s
|
||||||
|
JOIN resources r ON r.id = s.resource_id
|
||||||
|
JOIN nodes n ON n.id = r.node_id
|
||||||
|
WHERE n.name=$1 OR s.shared = true
|
||||||
|
ORDER BY s.storage_id""",
|
||||||
|
node,
|
||||||
|
)
|
||||||
|
return [storage_payload(row) for row in rows]
|
||||||
|
|
||||||
|
async def node_storage_index(request: Request, inputs: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
storage_id = str(values(inputs)["storage"])
|
||||||
|
await require_node(request, node)
|
||||||
|
await _storage_row(request, None, storage_id)
|
||||||
|
return subdirs("content", "status", "upload")
|
||||||
|
|
||||||
|
async def node_storage_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
storage_id = str(values(inputs)["storage"])
|
||||||
|
await require_node(request, node)
|
||||||
|
row = await _storage_row(request, None, storage_id)
|
||||||
|
return storage_payload(row)
|
||||||
|
|
||||||
|
async def node_storage_content(
|
||||||
|
request: Request, inputs: dict[str, Any]
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
storage_id = str(values(inputs)["storage"])
|
||||||
|
await require_node(request, node)
|
||||||
|
await _storage_row(request, None, storage_id)
|
||||||
|
resource_id = await database(request).pool.fetchval(
|
||||||
|
"SELECT resource_id FROM storages WHERE storage_id=$1",
|
||||||
|
storage_id,
|
||||||
|
)
|
||||||
|
contents = await database(request).pool.fetch(
|
||||||
|
"""SELECT volume_id, content_type, size_bytes, metadata, created_at
|
||||||
|
FROM storage_contents WHERE storage_resource_id=$1 ORDER BY created_at DESC""",
|
||||||
|
resource_id,
|
||||||
|
)
|
||||||
|
backups = await database(request).pool.fetch(
|
||||||
|
"""SELECT b.volume_id, b.size_bytes, b.metadata, b.created_at, r.external_id AS vmid
|
||||||
|
FROM backups b
|
||||||
|
LEFT JOIN resources r ON r.id = b.resource_id
|
||||||
|
WHERE b.storage_resource_id=$1
|
||||||
|
ORDER BY b.created_at DESC""",
|
||||||
|
resource_id,
|
||||||
|
)
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
for item in contents:
|
||||||
|
metadata = state(item["metadata"])
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"volid": str(item["volume_id"]),
|
||||||
|
"content": str(item["content_type"]),
|
||||||
|
"size": int(item["size_bytes"]),
|
||||||
|
"format": metadata.get("format", "raw"),
|
||||||
|
"ctime": int(item["created_at"].timestamp()),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for item in backups:
|
||||||
|
metadata = state(item["metadata"])
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"volid": str(item["volume_id"]),
|
||||||
|
"content": "backup",
|
||||||
|
"size": int(item["size_bytes"]),
|
||||||
|
"format": "vma.zst",
|
||||||
|
"vmid": int(item["vmid"]) if item["vmid"] is not None else None,
|
||||||
|
"notes": metadata.get("notes-template"),
|
||||||
|
"ctime": int(item["created_at"].timestamp()),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _content_item(
|
||||||
|
request: Request, storage_resource_id: object, volume_id: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
row = await database(request).pool.fetchrow(
|
||||||
|
"""SELECT volume_id, content_type, size_bytes, metadata, created_at
|
||||||
|
FROM storage_contents WHERE storage_resource_id=$1 AND volume_id=$2""",
|
||||||
|
storage_resource_id,
|
||||||
|
volume_id,
|
||||||
|
)
|
||||||
|
if row is not None:
|
||||||
|
metadata = state(row["metadata"])
|
||||||
|
return {
|
||||||
|
"volid": str(row["volume_id"]),
|
||||||
|
"content": str(row["content_type"]),
|
||||||
|
"size": int(row["size_bytes"]),
|
||||||
|
"format": metadata.get("format", "raw"),
|
||||||
|
"ctime": int(row["created_at"].timestamp()),
|
||||||
|
}
|
||||||
|
backup = await database(request).pool.fetchrow(
|
||||||
|
"""SELECT b.volume_id, b.size_bytes, b.metadata, b.created_at, r.external_id AS vmid
|
||||||
|
FROM backups b
|
||||||
|
LEFT JOIN resources r ON r.id = b.resource_id
|
||||||
|
WHERE b.storage_resource_id=$1 AND b.volume_id=$2""",
|
||||||
|
storage_resource_id,
|
||||||
|
volume_id,
|
||||||
|
)
|
||||||
|
if backup is None:
|
||||||
|
raise ApiError(404, "volume does not exist")
|
||||||
|
metadata = state(backup["metadata"])
|
||||||
|
return {
|
||||||
|
"volid": str(backup["volume_id"]),
|
||||||
|
"content": "backup",
|
||||||
|
"size": int(backup["size_bytes"]),
|
||||||
|
"format": "vma.zst",
|
||||||
|
"vmid": int(backup["vmid"]) if backup["vmid"] is not None else None,
|
||||||
|
"notes": metadata.get("notes-template"),
|
||||||
|
"ctime": int(backup["created_at"].timestamp()),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def node_storage_content_get(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
storage_id = str(values(inputs)["storage"])
|
||||||
|
volume_id = str(values(inputs)["volume"])
|
||||||
|
await require_node(request, node)
|
||||||
|
await _storage_row(request, None, storage_id)
|
||||||
|
resource_id = await database(request).pool.fetchval(
|
||||||
|
"SELECT resource_id FROM storages WHERE storage_id=$1",
|
||||||
|
storage_id,
|
||||||
|
)
|
||||||
|
return await _content_item(request, resource_id, volume_id)
|
||||||
|
|
||||||
|
async def node_storage_content_delete(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
storage_id = str(values(inputs)["storage"])
|
||||||
|
volume_id = str(values(inputs)["volume"])
|
||||||
|
await require_node(request, node)
|
||||||
|
await _storage_row(request, None, storage_id)
|
||||||
|
resource_id = await database(request).pool.fetchval(
|
||||||
|
"SELECT resource_id FROM storages WHERE storage_id=$1",
|
||||||
|
storage_id,
|
||||||
|
)
|
||||||
|
status = await database(request).pool.execute(
|
||||||
|
"DELETE FROM storage_contents WHERE storage_resource_id=$1 AND volume_id=$2",
|
||||||
|
resource_id,
|
||||||
|
volume_id,
|
||||||
|
)
|
||||||
|
if status == "DELETE 1":
|
||||||
|
return
|
||||||
|
status = await database(request).pool.execute(
|
||||||
|
"DELETE FROM backups WHERE storage_resource_id=$1 AND volume_id=$2",
|
||||||
|
resource_id,
|
||||||
|
volume_id,
|
||||||
|
)
|
||||||
|
if status != "DELETE 1":
|
||||||
|
raise ApiError(404, "volume does not exist")
|
||||||
|
|
||||||
|
async def node_storage_upload(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
storage_id = str(values(inputs)["storage"])
|
||||||
|
await require_node(request, node)
|
||||||
|
await _storage_row(request, None, storage_id)
|
||||||
|
payload = values(inputs)
|
||||||
|
filename = str(payload.get("filename") or "upload.bin")
|
||||||
|
content_type = str(payload.get("content") or "iso")
|
||||||
|
raw_size = payload.get("size") or 0
|
||||||
|
try:
|
||||||
|
size = int(raw_size)
|
||||||
|
except (TypeError, ValueError) as error:
|
||||||
|
raise ApiError(400, "invalid size") from error
|
||||||
|
volume_id = f"{storage_id}:{content_type}/{filename}"
|
||||||
|
resource_id = await database(request).pool.fetchval(
|
||||||
|
"SELECT resource_id FROM storages WHERE storage_id=$1",
|
||||||
|
storage_id,
|
||||||
|
)
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"""INSERT INTO storage_contents(
|
||||||
|
id, storage_resource_id, volume_id, content_type, size_bytes, metadata
|
||||||
|
) VALUES(gen_random_uuid(), $1, $2, $3, $4, $5::jsonb)
|
||||||
|
ON CONFLICT (storage_resource_id, volume_id) DO UPDATE
|
||||||
|
SET size_bytes=EXCLUDED.size_bytes,
|
||||||
|
content_type=EXCLUDED.content_type,
|
||||||
|
metadata=EXCLUDED.metadata""",
|
||||||
|
resource_id,
|
||||||
|
volume_id,
|
||||||
|
content_type,
|
||||||
|
size,
|
||||||
|
json.dumps({"filename": filename, "source": "upload"}, sort_keys=True),
|
||||||
|
)
|
||||||
|
return {"uploadid": volume_id, "filename": filename, "size": size, "volid": volume_id}
|
||||||
|
|
||||||
|
async def node_storage_prunebackups(
|
||||||
|
request: Request, inputs: dict[str, Any]
|
||||||
|
) -> list[dict[str, Any]] | None:
|
||||||
|
node = str(values(inputs)["node"])
|
||||||
|
storage_id = str(values(inputs)["storage"])
|
||||||
|
await require_node(request, node)
|
||||||
|
await _storage_row(request, None, storage_id)
|
||||||
|
resource_id = await database(request).pool.fetchval(
|
||||||
|
"SELECT resource_id FROM storages WHERE storage_id=$1",
|
||||||
|
storage_id,
|
||||||
|
)
|
||||||
|
if request.method == "DELETE":
|
||||||
|
keep = int(values(inputs).get("keep-last") or values(inputs).get("keep_last") or 1)
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"""DELETE FROM backups
|
||||||
|
WHERE storage_resource_id=$1 AND id IN (
|
||||||
|
SELECT id FROM backups
|
||||||
|
WHERE storage_resource_id=$1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
OFFSET $2
|
||||||
|
)""",
|
||||||
|
resource_id,
|
||||||
|
keep,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
rows = await database(request).pool.fetch(
|
||||||
|
"""SELECT volume_id, size_bytes, created_at FROM backups
|
||||||
|
WHERE storage_resource_id=$1 ORDER BY created_at DESC""",
|
||||||
|
resource_id,
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"volid": str(row["volume_id"]),
|
||||||
|
"size": int(row["size_bytes"]),
|
||||||
|
"ctime": int(row["created_at"].timestamp()),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
async def content_copy(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
storage_id = str(payload["storage"])
|
||||||
|
volume = str(payload["volume"])
|
||||||
|
await require_node(request, node)
|
||||||
|
await _storage_row(request, None, storage_id)
|
||||||
|
resource_id = await database(request).pool.fetchval(
|
||||||
|
"SELECT resource_id FROM storages WHERE storage_id=$1",
|
||||||
|
storage_id,
|
||||||
|
)
|
||||||
|
row = await database(request).pool.fetchrow(
|
||||||
|
"""SELECT volume_id, content_type, size_bytes, metadata
|
||||||
|
FROM storage_contents WHERE storage_resource_id=$1 AND volume_id=$2""",
|
||||||
|
resource_id,
|
||||||
|
volume,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "volume does not exist")
|
||||||
|
target = str(payload.get("target") or f"{volume}-copy")
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"""INSERT INTO storage_contents(
|
||||||
|
id, storage_resource_id, volume_id, content_type, size_bytes, metadata
|
||||||
|
) VALUES(gen_random_uuid(), $1, $2, $3, $4, $5::jsonb)
|
||||||
|
ON CONFLICT (storage_resource_id, volume_id) DO UPDATE
|
||||||
|
SET size_bytes=EXCLUDED.size_bytes, metadata=EXCLUDED.metadata""",
|
||||||
|
resource_id,
|
||||||
|
target,
|
||||||
|
row["content_type"],
|
||||||
|
row["size_bytes"],
|
||||||
|
json.dumps({**state(row["metadata"]), "copied_from": volume}, sort_keys=True),
|
||||||
|
)
|
||||||
|
return f"UPID:{node}:copy:{target}"
|
||||||
|
|
||||||
|
async def content_update(request: Request, inputs: dict[str, Any]) -> None:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
storage_id = str(payload["storage"])
|
||||||
|
volume = str(payload["volume"])
|
||||||
|
await require_node(request, node)
|
||||||
|
resource_id = await database(request).pool.fetchval(
|
||||||
|
"SELECT resource_id FROM storages WHERE storage_id=$1",
|
||||||
|
storage_id,
|
||||||
|
)
|
||||||
|
row = await database(request).pool.fetchrow(
|
||||||
|
"""SELECT metadata FROM storage_contents
|
||||||
|
WHERE storage_resource_id=$1 AND volume_id=$2""",
|
||||||
|
resource_id,
|
||||||
|
volume,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(404, "volume does not exist")
|
||||||
|
meta = state(row["metadata"])
|
||||||
|
if "notes" in payload:
|
||||||
|
meta["notes"] = payload["notes"]
|
||||||
|
if "protected" in payload:
|
||||||
|
meta["protected"] = int(bool(payload["protected"]))
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"""UPDATE storage_contents SET metadata=$3::jsonb
|
||||||
|
WHERE storage_resource_id=$1 AND volume_id=$2""",
|
||||||
|
resource_id,
|
||||||
|
volume,
|
||||||
|
json.dumps(meta, sort_keys=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def download_url(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
storage_id = str(payload["storage"])
|
||||||
|
await require_node(request, node)
|
||||||
|
await _storage_row(request, None, storage_id)
|
||||||
|
filename = str(payload.get("filename") or "download.bin")
|
||||||
|
content_type = str(payload.get("content") or "iso")
|
||||||
|
volume_id = f"{storage_id}:{content_type}/{filename}"
|
||||||
|
resource_id = await database(request).pool.fetchval(
|
||||||
|
"SELECT resource_id FROM storages WHERE storage_id=$1",
|
||||||
|
storage_id,
|
||||||
|
)
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"""INSERT INTO storage_contents(
|
||||||
|
id, storage_resource_id, volume_id, content_type, size_bytes, metadata
|
||||||
|
) VALUES(gen_random_uuid(), $1, $2, $3, 0, $4::jsonb)
|
||||||
|
ON CONFLICT (storage_resource_id, volume_id) DO UPDATE
|
||||||
|
SET metadata=EXCLUDED.metadata""",
|
||||||
|
resource_id,
|
||||||
|
volume_id,
|
||||||
|
content_type,
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"filename": filename,
|
||||||
|
"url": payload.get("url"),
|
||||||
|
"source": "download-url",
|
||||||
|
},
|
||||||
|
sort_keys=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return f"UPID:{node}:download:{filename}"
|
||||||
|
|
||||||
|
async def oci_pull(request: Request, inputs: dict[str, Any]) -> str:
|
||||||
|
payload = values(inputs)
|
||||||
|
node = str(payload["node"])
|
||||||
|
storage_id = str(payload["storage"])
|
||||||
|
await require_node(request, node)
|
||||||
|
await _storage_row(request, None, storage_id)
|
||||||
|
reference = str(payload.get("reference") or "image:latest")
|
||||||
|
filename = str(payload.get("filename") or reference.replace("/", "_"))
|
||||||
|
volume_id = f"{storage_id}:import/{filename}"
|
||||||
|
resource_id = await database(request).pool.fetchval(
|
||||||
|
"SELECT resource_id FROM storages WHERE storage_id=$1",
|
||||||
|
storage_id,
|
||||||
|
)
|
||||||
|
await database(request).pool.execute(
|
||||||
|
"""INSERT INTO storage_contents(
|
||||||
|
id, storage_resource_id, volume_id, content_type, size_bytes, metadata
|
||||||
|
) VALUES(gen_random_uuid(), $1, $2, 'import', 0, $3::jsonb)
|
||||||
|
ON CONFLICT (storage_resource_id, volume_id) DO UPDATE
|
||||||
|
SET metadata=EXCLUDED.metadata""",
|
||||||
|
resource_id,
|
||||||
|
volume_id,
|
||||||
|
json.dumps({"reference": reference, "source": "oci"}, sort_keys=True),
|
||||||
|
)
|
||||||
|
return f"UPID:{node}:oci-pull:{filename}"
|
||||||
|
|
||||||
|
async def file_restore_list(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
payload = values(inputs)
|
||||||
|
await require_node(request, str(payload["node"]))
|
||||||
|
await _storage_row(request, None, str(payload["storage"]))
|
||||||
|
filepath = str(payload.get("filepath") or "/")
|
||||||
|
return [{"filepath": filepath.rstrip("/") + "/etc", "type": "d", "text": "etc"}]
|
||||||
|
|
||||||
|
async def file_restore_download(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = values(inputs)
|
||||||
|
await require_node(request, str(payload["node"]))
|
||||||
|
await _storage_row(request, None, str(payload["storage"]))
|
||||||
|
return {
|
||||||
|
"download-url": f"/api2/json/nodes/{payload['node']}/storage/"
|
||||||
|
f"{payload['storage']}/file-restore/download",
|
||||||
|
"filepath": payload.get("filepath") or "/",
|
||||||
|
"volume": payload.get("volume"),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def storage_identity(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = values(inputs)
|
||||||
|
await require_node(request, str(payload["node"]))
|
||||||
|
row = await _storage_row(request, None, str(payload["storage"]))
|
||||||
|
return {
|
||||||
|
"storage": str(row["storage_id"]),
|
||||||
|
"type": str(row["storage_type"]),
|
||||||
|
"fingerprint": f"sim-{row['storage_id']}",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def import_metadata(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = values(inputs)
|
||||||
|
await require_node(request, str(payload["node"]))
|
||||||
|
storage_id = str(payload["storage"])
|
||||||
|
volume = str(payload["volume"])
|
||||||
|
await _storage_row(request, None, storage_id)
|
||||||
|
return {
|
||||||
|
"type": "qemu",
|
||||||
|
"source": volume,
|
||||||
|
"disks": {"scsi0": f"{storage_id}:0/vm-import.raw"},
|
||||||
|
"net0": "virtio,bridge=vmbr0",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def storage_rrd(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = values(inputs)
|
||||||
|
await require_node(request, str(payload["node"]))
|
||||||
|
storage_id = str(payload["storage"])
|
||||||
|
await _storage_row(request, None, storage_id)
|
||||||
|
return {"filename": f"pve-storage-{storage_id}.rrd"}
|
||||||
|
|
||||||
|
async def storage_rrddata(request: Request, inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
payload = values(inputs)
|
||||||
|
await require_node(request, str(payload["node"]))
|
||||||
|
await _storage_row(request, None, str(payload["storage"]))
|
||||||
|
return [
|
||||||
|
{"time": 1_700_000_000, "used": 10, "total": 100},
|
||||||
|
{"time": 1_700_000_060, "used": 12, "total": 100},
|
||||||
|
]
|
||||||
|
|
||||||
|
registry.register("/storage", "GET", storage_ids)
|
||||||
|
registry.register("/storage", "POST", storage_create)
|
||||||
|
registry.register("/storage/{storage}", "GET", storage_get)
|
||||||
|
registry.register("/storage/{storage}", "PUT", storage_update)
|
||||||
|
registry.register("/storage/{storage}", "DELETE", storage_delete)
|
||||||
|
registry.register("/nodes/{node}/storage", "GET", node_storage_list)
|
||||||
|
registry.register("/nodes/{node}/storage/{storage}", "GET", node_storage_index)
|
||||||
|
registry.register("/nodes/{node}/storage/{storage}/status", "GET", node_storage_status)
|
||||||
|
registry.register("/nodes/{node}/storage/{storage}/content", "GET", node_storage_content)
|
||||||
|
registry.register("/nodes/{node}/storage/{storage}/content", "POST", node_storage_upload)
|
||||||
|
registry.register(
|
||||||
|
"/nodes/{node}/storage/{storage}/content/{volume}", "GET", node_storage_content_get
|
||||||
|
)
|
||||||
|
registry.register(
|
||||||
|
"/nodes/{node}/storage/{storage}/content/{volume}", "DELETE", node_storage_content_delete
|
||||||
|
)
|
||||||
|
registry.register("/nodes/{node}/storage/{storage}/upload", "POST", node_storage_upload)
|
||||||
|
registry.register(
|
||||||
|
"/nodes/{node}/storage/{storage}/prunebackups", "GET", node_storage_prunebackups
|
||||||
|
)
|
||||||
|
registry.register(
|
||||||
|
"/nodes/{node}/storage/{storage}/prunebackups", "DELETE", node_storage_prunebackups
|
||||||
|
)
|
||||||
|
registry.register("/nodes/{node}/storage/{storage}/content/{volume}", "POST", content_copy)
|
||||||
|
registry.register("/nodes/{node}/storage/{storage}/content/{volume}", "PUT", content_update)
|
||||||
|
registry.register("/nodes/{node}/storage/{storage}/download-url", "POST", download_url)
|
||||||
|
registry.register("/nodes/{node}/storage/{storage}/oci-registry-pull", "POST", oci_pull)
|
||||||
|
registry.register("/nodes/{node}/storage/{storage}/file-restore/list", "GET", file_restore_list)
|
||||||
|
registry.register(
|
||||||
|
"/nodes/{node}/storage/{storage}/file-restore/download", "GET", file_restore_download
|
||||||
|
)
|
||||||
|
registry.register("/nodes/{node}/storage/{storage}/identity", "GET", storage_identity)
|
||||||
|
registry.register("/nodes/{node}/storage/{storage}/import-metadata", "GET", import_metadata)
|
||||||
|
registry.register("/nodes/{node}/storage/{storage}/rrd", "GET", storage_rrd)
|
||||||
|
registry.register("/nodes/{node}/storage/{storage}/rrddata", "GET", storage_rrddata)
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""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.openstack.demo_cloud import DEMO_PROFILE
|
||||||
|
from app.openstack.seed import seed_openstack
|
||||||
|
|
||||||
|
async with database.pool.acquire() as connection:
|
||||||
|
# Preserve a loaded demo cloud across restarts; only seed minimal lab otherwise.
|
||||||
|
try:
|
||||||
|
profile = await connection.fetchval(
|
||||||
|
"SELECT value FROM os_demo_meta WHERE key = 'profile'"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
profile = None
|
||||||
|
if profile != DEMO_PROFILE:
|
||||||
|
await seed_openstack(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())
|
||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
"""FastAPI application factory and ASGI entry point."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
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.api.registry import HandlerRegistry
|
||||||
|
from app.config import Settings, get_settings
|
||||||
|
from app.contracts.model import Snapshot
|
||||||
|
from app.contracts.runtime import apply_runtime_contract, contract_store_root
|
||||||
|
from app.db.pool import AsyncpgDatabase, Database
|
||||||
|
from app.handlers.core import build_core_handlers
|
||||||
|
from app.lifespan import DatabaseFactory, WorkerFactory, create_lifespan, default_database_factory
|
||||||
|
from app.logging import configure_logging
|
||||||
|
from app.observability.health import router as health_router
|
||||||
|
from app.simulation.clock import AcceleratedClock
|
||||||
|
from app.tasks.backup import backup_handler
|
||||||
|
from app.tasks.lxc import lxc_handler
|
||||||
|
from app.tasks.qemu import qemu_handler
|
||||||
|
from app.tasks.repository import TaskRepository
|
||||||
|
from app.tasks.worker import TaskWorker
|
||||||
|
from app.openstack.mount import mount_openstack_routes
|
||||||
|
from app.web.routes import router as web_router
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(
|
||||||
|
settings: Settings | None = None,
|
||||||
|
database_factory: DatabaseFactory = default_database_factory,
|
||||||
|
handlers: HandlerRegistry | None = None,
|
||||||
|
worker_factories: tuple[WorkerFactory, ...] | None = None,
|
||||||
|
) -> FastAPI:
|
||||||
|
"""Create an isolated application instance with explicit resource factories."""
|
||||||
|
|
||||||
|
resolved = settings or get_settings()
|
||||||
|
configure_logging(resolved.log_level)
|
||||||
|
resolved_workers = worker_factories
|
||||||
|
if resolved_workers is None and resolved.contract_snapshot is not None and handlers is None:
|
||||||
|
|
||||||
|
def task_worker(database: Database) -> TaskWorker:
|
||||||
|
adapter = cast(AsyncpgDatabase, database)
|
||||||
|
repository = TaskRepository(adapter.pool)
|
||||||
|
clock = AcceleratedClock(resolved.simulation_time_scale)
|
||||||
|
qemu = qemu_handler(repository, clock)
|
||||||
|
lxc = lxc_handler(repository, clock)
|
||||||
|
backup = backup_handler(repository, clock)
|
||||||
|
return TaskWorker(
|
||||||
|
repository,
|
||||||
|
"simulator-worker",
|
||||||
|
{
|
||||||
|
"qemu-clone": qemu,
|
||||||
|
"qemu-create": qemu,
|
||||||
|
"qemu-delete": qemu,
|
||||||
|
"qemu-reboot": qemu,
|
||||||
|
"qemu-reset": qemu,
|
||||||
|
"qemu-resume": qemu,
|
||||||
|
"qemu-shutdown": qemu,
|
||||||
|
"qemu-migrate": qemu,
|
||||||
|
"qemu-move-disk": qemu,
|
||||||
|
"qemu-snapshot-create": qemu,
|
||||||
|
"qemu-snapshot-delete": qemu,
|
||||||
|
"qemu-snapshot-rollback": qemu,
|
||||||
|
"qemu-start": qemu,
|
||||||
|
"qemu-stop": qemu,
|
||||||
|
"qemu-suspend": qemu,
|
||||||
|
"qemu-update": qemu,
|
||||||
|
"lxc-clone": lxc,
|
||||||
|
"lxc-create": lxc,
|
||||||
|
"lxc-delete": lxc,
|
||||||
|
"lxc-migrate": lxc,
|
||||||
|
"lxc-reboot": lxc,
|
||||||
|
"lxc-resume": lxc,
|
||||||
|
"lxc-shutdown": lxc,
|
||||||
|
"lxc-snapshot-create": lxc,
|
||||||
|
"lxc-snapshot-delete": lxc,
|
||||||
|
"lxc-snapshot-rollback": lxc,
|
||||||
|
"lxc-start": lxc,
|
||||||
|
"lxc-stop": lxc,
|
||||||
|
"lxc-suspend": lxc,
|
||||||
|
"vzdump": backup,
|
||||||
|
"aptupdate": backup,
|
||||||
|
},
|
||||||
|
concurrency=resolved.task_worker_concurrency,
|
||||||
|
lease_seconds=resolved.task_lease_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
resolved_workers = (task_worker,)
|
||||||
|
app = FastAPI(
|
||||||
|
title=resolved.app_name,
|
||||||
|
version="0.1.0",
|
||||||
|
openapi_tags=openapi_tag_metadata(),
|
||||||
|
lifespan=create_lifespan(resolved, database_factory, resolved_workers or ()),
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
import os as _os
|
||||||
|
|
||||||
|
mount_openstack_routes(app, series=_os.environ.get("OPENSTACK_SERIES", "dalmatian"))
|
||||||
|
if resolved.contract_snapshot is not None:
|
||||||
|
snapshot = Snapshot.model_validate_json(resolved.contract_snapshot.read_bytes())
|
||||||
|
resolved_handlers = handlers or build_core_handlers(resolved)
|
||||||
|
apply_runtime_contract(
|
||||||
|
app,
|
||||||
|
snapshot,
|
||||||
|
handlers=resolved_handlers,
|
||||||
|
store_root=contract_store_root(resolved),
|
||||||
|
fallback=resolved.contract_fallback,
|
||||||
|
settings=resolved,
|
||||||
|
require_evidence_match=True,
|
||||||
|
register_admin=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
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 @@
|
|||||||
|
"""OpenStack API surfaces (Keystone, Nova, Neutron, Glance, Cinder)."""
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
"""Keystone token issue / validation helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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.openstack.catalog import build_catalog_from_db
|
||||||
|
from app.openstack.errors import OpenStackError
|
||||||
|
from app.security.auth import verify_secret
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TokenContext:
|
||||||
|
token_id: str
|
||||||
|
user_id: UUID
|
||||||
|
user_name: str
|
||||||
|
project_id: UUID | None
|
||||||
|
project_name: str | None
|
||||||
|
roles: tuple[str, ...]
|
||||||
|
expires_at: datetime
|
||||||
|
is_admin: bool
|
||||||
|
|
||||||
|
|
||||||
|
async def issue_token(
|
||||||
|
conn: Connection,
|
||||||
|
*,
|
||||||
|
user_name: str,
|
||||||
|
password: str,
|
||||||
|
project_name: str | None,
|
||||||
|
domain_name: str = "Default",
|
||||||
|
host: str = "localhost",
|
||||||
|
scheme: str = "http",
|
||||||
|
ttl_seconds: int = 3600,
|
||||||
|
) -> tuple[str, dict[str, Any]]:
|
||||||
|
domain = await conn.fetchrow(
|
||||||
|
"SELECT id, name FROM os_domains WHERE name = $1 AND enabled", domain_name
|
||||||
|
)
|
||||||
|
if domain is None:
|
||||||
|
raise OpenStackError("Unauthorized", "Invalid user credentials", status_code=401)
|
||||||
|
|
||||||
|
user = await conn.fetchrow(
|
||||||
|
"""SELECT id, name, password_hash, enabled
|
||||||
|
FROM os_users WHERE domain_id = $1 AND name = $2""",
|
||||||
|
domain["id"],
|
||||||
|
user_name,
|
||||||
|
)
|
||||||
|
if user is None or not user["enabled"] or not verify_secret(password, user["password_hash"]):
|
||||||
|
raise OpenStackError(
|
||||||
|
"Unauthorized", "The request you have made requires authentication.", status_code=401
|
||||||
|
)
|
||||||
|
|
||||||
|
project = None
|
||||||
|
if project_name:
|
||||||
|
project = await conn.fetchrow(
|
||||||
|
"""SELECT id, name, enabled FROM os_projects
|
||||||
|
WHERE domain_id = $1 AND name = $2""",
|
||||||
|
domain["id"],
|
||||||
|
project_name,
|
||||||
|
)
|
||||||
|
if project is None or not project["enabled"]:
|
||||||
|
raise OpenStackError("Unauthorized", "Project not found or disabled", status_code=401)
|
||||||
|
assignment = await conn.fetchval(
|
||||||
|
"""SELECT 1 FROM os_role_assignments
|
||||||
|
WHERE user_id = $1 AND project_id = $2 LIMIT 1""",
|
||||||
|
user["id"],
|
||||||
|
project["id"],
|
||||||
|
)
|
||||||
|
if assignment is None:
|
||||||
|
raise OpenStackError("Forbidden", "User is not authorized for project", status_code=403)
|
||||||
|
|
||||||
|
roles_rows = []
|
||||||
|
if project is not None:
|
||||||
|
roles_rows = await conn.fetch(
|
||||||
|
"""SELECT r.name FROM os_role_assignments a
|
||||||
|
JOIN os_roles r ON r.id = a.role_id
|
||||||
|
WHERE a.user_id = $1 AND a.project_id = $2""",
|
||||||
|
user["id"],
|
||||||
|
project["id"],
|
||||||
|
)
|
||||||
|
role_names = [str(row["name"]) for row in roles_rows]
|
||||||
|
|
||||||
|
token_id = secrets.token_hex(16)
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
expires = now + timedelta(seconds=ttl_seconds)
|
||||||
|
await conn.execute(
|
||||||
|
"""INSERT INTO os_tokens(id, user_id, project_id, expires_at, issued_at, revoked)
|
||||||
|
VALUES($1, $2, $3, $4, $5, false)""",
|
||||||
|
token_id,
|
||||||
|
user["id"],
|
||||||
|
project["id"] if project else None,
|
||||||
|
expires,
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
|
||||||
|
catalog = await build_catalog_from_db(conn, host, scheme=scheme) if project is not None else []
|
||||||
|
body = {
|
||||||
|
"token": {
|
||||||
|
# Lab convenience: token id also in body so browser UIs need not rely on
|
||||||
|
# Access-Control-Expose-Headers for X-Subject-Token.
|
||||||
|
"id": token_id,
|
||||||
|
"methods": ["password"],
|
||||||
|
"expires_at": expires.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||||
|
"issued_at": now.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||||
|
"user": {
|
||||||
|
"id": str(user["id"]),
|
||||||
|
"name": user["name"],
|
||||||
|
"domain": {"id": str(domain["id"]), "name": domain["name"]},
|
||||||
|
},
|
||||||
|
"audit_ids": [secrets.token_urlsafe(8)],
|
||||||
|
"roles": [{"id": name, "name": name} for name in role_names],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if project is not None:
|
||||||
|
body["token"]["project"] = {
|
||||||
|
"id": str(project["id"]),
|
||||||
|
"name": project["name"],
|
||||||
|
"domain": {"id": str(domain["id"]), "name": domain["name"]},
|
||||||
|
}
|
||||||
|
body["token"]["catalog"] = catalog
|
||||||
|
return token_id, body
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_token(conn: Connection, token_id: str) -> TokenContext:
|
||||||
|
if not token_id:
|
||||||
|
raise OpenStackError(
|
||||||
|
"Unauthorized",
|
||||||
|
"The request you have made requires authentication.",
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""SELECT t.id, t.user_id, t.project_id, t.expires_at, t.revoked,
|
||||||
|
u.name AS user_name, p.name AS project_name
|
||||||
|
FROM os_tokens t
|
||||||
|
JOIN os_users u ON u.id = t.user_id
|
||||||
|
LEFT JOIN os_projects p ON p.id = t.project_id
|
||||||
|
WHERE t.id = $1""",
|
||||||
|
token_id,
|
||||||
|
)
|
||||||
|
if row is None or row["revoked"]:
|
||||||
|
raise OpenStackError("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 OpenStackError("Unauthorized", "Token has expired", status_code=401)
|
||||||
|
|
||||||
|
roles: list[str] = []
|
||||||
|
if row["project_id"] is not None:
|
||||||
|
roles = [
|
||||||
|
str(r["name"])
|
||||||
|
for r in await conn.fetch(
|
||||||
|
"""SELECT r.name FROM os_role_assignments a
|
||||||
|
JOIN os_roles r ON r.id = a.role_id
|
||||||
|
WHERE a.user_id = $1 AND a.project_id = $2""",
|
||||||
|
row["user_id"],
|
||||||
|
row["project_id"],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
is_admin = "admin" in roles
|
||||||
|
return TokenContext(
|
||||||
|
token_id=str(row["id"]),
|
||||||
|
user_id=row["user_id"],
|
||||||
|
user_name=str(row["user_name"]),
|
||||||
|
project_id=row["project_id"],
|
||||||
|
project_name=str(row["project_name"]) if row["project_name"] else None,
|
||||||
|
roles=tuple(roles),
|
||||||
|
expires_at=expires,
|
||||||
|
is_admin=is_admin,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_token(headers: dict[str, str]) -> str | None:
|
||||||
|
# Case-insensitive lookup
|
||||||
|
lower = {k.lower(): v for k, v in headers.items()}
|
||||||
|
if "x-auth-token" in lower:
|
||||||
|
return lower["x-auth-token"]
|
||||||
|
auth = lower.get("authorization", "")
|
||||||
|
if auth.lower().startswith("bearer "):
|
||||||
|
return auth.split(" ", 1)[1].strip()
|
||||||
|
return None
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""Service catalog builders — loaded from PostgreSQL discovery seed."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from asyncpg import Connection
|
||||||
|
|
||||||
|
from app.openstack.db_docs import require_doc
|
||||||
|
from app.openstack.errors import OpenStackError
|
||||||
|
from app.openstack.surface import catalog_entries
|
||||||
|
|
||||||
|
|
||||||
|
def public_base(host: str, port: int, *, scheme: str = "http") -> str:
|
||||||
|
host = host.split("%")[0]
|
||||||
|
return f"{scheme}://{host}:{port}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_catalog(host: str, *, scheme: str = "http") -> list[dict[str, Any]]:
|
||||||
|
"""Sync helper for offline tests/tools (no DB). Runtime catalog uses DB only."""
|
||||||
|
|
||||||
|
return catalog_entries(host, scheme=scheme)
|
||||||
|
|
||||||
|
|
||||||
|
async def build_catalog_from_db(
|
||||||
|
conn: Connection,
|
||||||
|
host: str,
|
||||||
|
*,
|
||||||
|
scheme: str = "http",
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Render Keystone catalog from the seeded DB template."""
|
||||||
|
|
||||||
|
doc = await require_doc(
|
||||||
|
conn,
|
||||||
|
service="keystone",
|
||||||
|
resource_type="service_catalog_template",
|
||||||
|
name="default",
|
||||||
|
)
|
||||||
|
catalog = doc.get("catalog") or doc.get("services") or []
|
||||||
|
rendered = json.dumps(catalog).replace("__HOST__", host).replace("__SCHEME__", scheme)
|
||||||
|
data = json.loads(rendered)
|
||||||
|
if not isinstance(data, list):
|
||||||
|
raise OpenStackError(
|
||||||
|
"NotFound",
|
||||||
|
"keystone/service_catalog_template/default has invalid catalog shape",
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
return data
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"""Load and hot-swap OpenStack series contract packs from contracts/openstack/."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.openstack.opspec import OperationSpec, SeriesManifest, ServicePack
|
||||||
|
|
||||||
|
_CONTRACTS_ROOT = Path(__file__).resolve().parents[2] / "contracts" / "openstack"
|
||||||
|
|
||||||
|
_SERIES_MAJOR = {
|
||||||
|
"yoga": 6,
|
||||||
|
"antelope": 7,
|
||||||
|
"caracal": 8,
|
||||||
|
"dalmatian": 9,
|
||||||
|
}
|
||||||
|
_MAJOR_SERIES = {v: k for k, v in _SERIES_MAJOR.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def contracts_root() -> Path:
|
||||||
|
return _CONTRACTS_ROOT
|
||||||
|
|
||||||
|
|
||||||
|
def series_for_major(major: int) -> str:
|
||||||
|
return _MAJOR_SERIES.get(major, "dalmatian")
|
||||||
|
|
||||||
|
|
||||||
|
def major_for_series(series: str) -> int:
|
||||||
|
return _SERIES_MAJOR.get(series.lower(), 9)
|
||||||
|
|
||||||
|
|
||||||
|
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)),
|
||||||
|
"operation_count": data.get("operation_count", 0),
|
||||||
|
"service_count": data.get("service_count", 0),
|
||||||
|
"checksum": data.get("checksum", ""),
|
||||||
|
"generated_at": data.get("generated_at", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _op_from_dict(service: str, raw: dict[str, Any]) -> OperationSpec:
|
||||||
|
return OperationSpec(
|
||||||
|
operation_id=str(raw["operation_id"]),
|
||||||
|
method=raw["method"], # type: ignore[arg-type]
|
||||||
|
path=str(raw["path"]),
|
||||||
|
service=service,
|
||||||
|
resource_type=str(raw.get("resource_type") or "object"),
|
||||||
|
collection_key=raw.get("collection_key"),
|
||||||
|
item_key=raw.get("item_key"),
|
||||||
|
kind=raw.get("kind") or "collection", # type: ignore[arg-type]
|
||||||
|
status_code=int(raw.get("status_code") or 200),
|
||||||
|
create_status=int(raw.get("create_status") or raw.get("status_code") or 201),
|
||||||
|
microversion_min=raw.get("microversion_min"),
|
||||||
|
microversion_max=raw.get("microversion_max"),
|
||||||
|
requires_auth=bool(raw.get("requires_auth", True)),
|
||||||
|
requires_project=bool(raw.get("requires_project", True)),
|
||||||
|
action_name=raw.get("action_name"),
|
||||||
|
response_fixture=raw.get("response_fixture"),
|
||||||
|
notes=str(raw.get("notes") or ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_series_pack(series: str) -> dict[str, ServicePack]:
|
||||||
|
series = series.lower()
|
||||||
|
series_dir = contracts_root() / series
|
||||||
|
man_path = series_dir / "manifest.json"
|
||||||
|
if not man_path.is_file():
|
||||||
|
raise FileNotFoundError(f"OpenStack contract pack not found: {series_dir}")
|
||||||
|
packs: dict[str, ServicePack] = {}
|
||||||
|
for svc_dir in sorted(series_dir.iterdir()):
|
||||||
|
api = svc_dir / "api.json"
|
||||||
|
if not api.is_file():
|
||||||
|
continue
|
||||||
|
data = json.loads(api.read_text())
|
||||||
|
name = str(data["service"])
|
||||||
|
ops = [_op_from_dict(name, raw) for raw in data.get("operations") or []]
|
||||||
|
packs[name] = ServicePack(
|
||||||
|
name=name,
|
||||||
|
typ=str(data.get("type") or name),
|
||||||
|
port=int(data["port"]),
|
||||||
|
version_path=str(data.get("version_path") or "/"),
|
||||||
|
default_microversion=data.get("default_microversion"),
|
||||||
|
max_microversion=data.get("max_microversion"),
|
||||||
|
operations=ops,
|
||||||
|
)
|
||||||
|
return packs
|
||||||
|
|
||||||
|
|
||||||
|
def load_manifest(series: str) -> SeriesManifest:
|
||||||
|
data = json.loads((contracts_root() / series.lower() / "manifest.json").read_text())
|
||||||
|
services = list(data.get("services") or [])
|
||||||
|
return SeriesManifest(
|
||||||
|
series=str(data["series"]),
|
||||||
|
major=int(data["major"]),
|
||||||
|
services=services,
|
||||||
|
checksum=str(data.get("checksum") or ""),
|
||||||
|
generated_at=str(data.get("generated_at") or ""),
|
||||||
|
operation_count=int(data.get("operation_count") or 0),
|
||||||
|
service_count=int(data.get("service_count") or len(services)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ContractRuntime:
|
||||||
|
"""Process-wide active OpenStack contract pack + per-service microversion overrides."""
|
||||||
|
|
||||||
|
series: str = "dalmatian"
|
||||||
|
packs: dict[str, ServicePack] = field(default_factory=dict)
|
||||||
|
microversion_overrides: dict[str, str] = field(default_factory=dict)
|
||||||
|
_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.packs = load_series_pack(target)
|
||||||
|
self.series = target
|
||||||
|
man = load_manifest(target)
|
||||||
|
return {
|
||||||
|
"series": man.series,
|
||||||
|
"major": man.major,
|
||||||
|
"operation_count": man.operation_count,
|
||||||
|
"service_count": man.service_count,
|
||||||
|
"checksum": man.checksum,
|
||||||
|
"services": sorted(self.packs.keys()),
|
||||||
|
}
|
||||||
|
|
||||||
|
def summary(self) -> dict[str, Any]:
|
||||||
|
with self._lock:
|
||||||
|
man = None
|
||||||
|
try:
|
||||||
|
man = load_manifest(self.series)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
return {
|
||||||
|
"series": self.series,
|
||||||
|
"major": major_for_series(self.series),
|
||||||
|
"operation_count": sum(p.operation_count() for p in self.packs.values()),
|
||||||
|
"service_count": len(self.packs),
|
||||||
|
"checksum": man.checksum if man else "",
|
||||||
|
"microversion_overrides": dict(self.microversion_overrides),
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"name": p.name,
|
||||||
|
"type": p.typ,
|
||||||
|
"port": p.port,
|
||||||
|
"operation_count": p.operation_count(),
|
||||||
|
"default_microversion": p.default_microversion,
|
||||||
|
"max_microversion": p.max_microversion,
|
||||||
|
"active_microversion": self.microversion_overrides.get(
|
||||||
|
p.name, p.default_microversion
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for p in sorted(self.packs.values(), key=lambda x: x.name)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def set_microversion(self, service: str, version: str | None) -> None:
|
||||||
|
with self._lock:
|
||||||
|
if version is None:
|
||||||
|
self.microversion_overrides.pop(service, None)
|
||||||
|
else:
|
||||||
|
self.microversion_overrides[service] = version
|
||||||
|
|
||||||
|
def active_microversion(self, service: str) -> str | None:
|
||||||
|
with self._lock:
|
||||||
|
if service in self.microversion_overrides:
|
||||||
|
return self.microversion_overrides[service]
|
||||||
|
pack = self.packs.get(service)
|
||||||
|
return pack.default_microversion if pack else None
|
||||||
|
|
||||||
|
|
||||||
|
_RUNTIME = ContractRuntime()
|
||||||
|
|
||||||
|
|
||||||
|
def get_runtime() -> ContractRuntime:
|
||||||
|
return _RUNTIME
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_loaded(series: str = "dalmatian") -> ContractRuntime:
|
||||||
|
rt = get_runtime()
|
||||||
|
if not rt.packs:
|
||||||
|
try:
|
||||||
|
rt.reload(series)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
return rt
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""Read JSON documents stored in ``os_api_objects`` (discovery, schemas, catalog)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from asyncpg import Connection
|
||||||
|
|
||||||
|
from app.openstack.errors import OpenStackError
|
||||||
|
from app.openstack.ids import oid
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_doc(
|
||||||
|
conn: Connection,
|
||||||
|
*,
|
||||||
|
service: str,
|
||||||
|
resource_type: str,
|
||||||
|
name: str = "default",
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""SELECT data FROM os_api_objects
|
||||||
|
WHERE service=$1 AND resource_type=$2 AND name=$3
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT 1""",
|
||||||
|
service,
|
||||||
|
resource_type,
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
data = row["data"]
|
||||||
|
if isinstance(data, str):
|
||||||
|
data = json.loads(data)
|
||||||
|
return dict(data or {})
|
||||||
|
|
||||||
|
|
||||||
|
async def require_doc(
|
||||||
|
conn: Connection,
|
||||||
|
*,
|
||||||
|
service: str,
|
||||||
|
resource_type: str,
|
||||||
|
name: str = "default",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
doc = await fetch_doc(conn, service=service, resource_type=resource_type, name=name)
|
||||||
|
if doc is None:
|
||||||
|
raise OpenStackError(
|
||||||
|
"NotFound",
|
||||||
|
f"{service}/{resource_type}/{name} not seeded in database",
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
async def list_docs(
|
||||||
|
conn: Connection,
|
||||||
|
*,
|
||||||
|
service: str,
|
||||||
|
resource_type: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""SELECT id, name, status, data FROM os_api_objects
|
||||||
|
WHERE service=$1 AND resource_type=$2
|
||||||
|
ORDER BY created_at NULLS LAST, name""",
|
||||||
|
service,
|
||||||
|
resource_type,
|
||||||
|
)
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
data = row["data"]
|
||||||
|
if isinstance(data, str):
|
||||||
|
data = json.loads(data)
|
||||||
|
data = dict(data or {})
|
||||||
|
data.setdefault("id", str(row["id"]))
|
||||||
|
data.setdefault("name", row["name"])
|
||||||
|
data.setdefault("status", row["status"])
|
||||||
|
items.append(data)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_doc(
|
||||||
|
conn: Connection,
|
||||||
|
*,
|
||||||
|
service: str,
|
||||||
|
resource_type: str,
|
||||||
|
name: str,
|
||||||
|
data: dict[str, Any],
|
||||||
|
project_id: Any | None = None,
|
||||||
|
status: str = "ACTIVE",
|
||||||
|
) -> None:
|
||||||
|
item_id = oid(f"doc:{service}:{resource_type}:{name}")
|
||||||
|
payload = {"id": str(item_id), "name": name, **data}
|
||||||
|
await conn.execute(
|
||||||
|
"""INSERT INTO os_api_objects(id, service, resource_type, project_id, name, status, data)
|
||||||
|
VALUES($1,$2,$3,$4,$5,$6,$7::jsonb)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
data=EXCLUDED.data, status=EXCLUDED.status, updated_at=now()""",
|
||||||
|
item_id,
|
||||||
|
service,
|
||||||
|
resource_type,
|
||||||
|
project_id,
|
||||||
|
name,
|
||||||
|
status,
|
||||||
|
json.dumps(payload),
|
||||||
|
)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
|||||||
|
"""FastAPI dependencies for OpenStack routes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from asyncpg import Connection, Pool
|
||||||
|
from fastapi import Depends, Request
|
||||||
|
|
||||||
|
from app.db.pool import AsyncpgDatabase
|
||||||
|
from app.dependencies import get_database
|
||||||
|
from app.openstack.auth import TokenContext, extract_token, validate_token
|
||||||
|
from app.openstack.errors import OpenStackError
|
||||||
|
|
||||||
|
|
||||||
|
async def get_pool(request: Request) -> Pool:
|
||||||
|
database = get_database(request)
|
||||||
|
if not isinstance(database, AsyncpgDatabase):
|
||||||
|
raise OpenStackError("ServiceUnavailable", "Database unavailable", status_code=503)
|
||||||
|
return database.pool
|
||||||
|
|
||||||
|
|
||||||
|
async def get_conn(pool: Annotated[Pool, Depends(get_pool)]) -> Connection:
|
||||||
|
async with pool.acquire() as connection:
|
||||||
|
yield connection
|
||||||
|
|
||||||
|
|
||||||
|
async def require_token(
|
||||||
|
request: Request,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
) -> TokenContext:
|
||||||
|
token_id = extract_token({k: v for k, v in request.headers.items()})
|
||||||
|
if token_id is None:
|
||||||
|
raise OpenStackError(
|
||||||
|
"Unauthorized",
|
||||||
|
"The request you have made requires authentication.",
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
return await validate_token(conn, token_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def require_project_token(
|
||||||
|
ctx: Annotated[TokenContext, Depends(require_token)],
|
||||||
|
) -> TokenContext:
|
||||||
|
if ctx.project_id is None:
|
||||||
|
raise OpenStackError(
|
||||||
|
"Forbidden",
|
||||||
|
"A project-scoped token is required for this action.",
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
def request_public_host(request: Request, default: str = "localhost") -> str:
|
||||||
|
forwarded = request.headers.get("x-forwarded-host") or request.headers.get("host")
|
||||||
|
if not forwarded:
|
||||||
|
return default
|
||||||
|
host = forwarded.split(",")[0].strip()
|
||||||
|
# Strip port from Host header so catalog can attach service ports.
|
||||||
|
if host.startswith("["):
|
||||||
|
# [ipv6]:port
|
||||||
|
if "]" in host:
|
||||||
|
return host[1 : host.index("]")]
|
||||||
|
return host.strip("[]")
|
||||||
|
return host.rsplit(":", 1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def request_scheme(request: Request) -> str:
|
||||||
|
return request.headers.get("x-forwarded-proto") or request.url.scheme or "http"
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
"""Rewrite incoming requests onto /_os/<service>/… based on gateway port/header.
|
||||||
|
|
||||||
|
All OpenStack service routers are mounted under /_os/<service> so that
|
||||||
|
overlapping paths (/v3 for Keystone vs Cinder, /v1 for Heat vs Swift, …)
|
||||||
|
do not collide inside a single FastAPI process.
|
||||||
|
|
||||||
|
When the browser UI is served from the Keystone port (5000), relative fetches
|
||||||
|
like ``/v2.1/servers`` still arrive with ``X-OpenStack-Service: keystone``.
|
||||||
|
In that case we re-resolve the target service from the URL path (or from
|
||||||
|
``X-OpenStack-Route-Service`` set by the console).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response
|
||||||
|
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||||
|
|
||||||
|
from app.openstack.surface import SERVICES
|
||||||
|
|
||||||
|
_PORT_TO_SERVICE = {spec.port: spec.name for spec in SERVICES}
|
||||||
|
|
||||||
|
_SKIP_PREFIXES = (
|
||||||
|
"/_os/",
|
||||||
|
"/api2",
|
||||||
|
"/docs",
|
||||||
|
"/redoc",
|
||||||
|
"/openapi",
|
||||||
|
"/health",
|
||||||
|
"/metrics",
|
||||||
|
"/static",
|
||||||
|
"/ui",
|
||||||
|
"/favicon",
|
||||||
|
"/assets",
|
||||||
|
"/console",
|
||||||
|
)
|
||||||
|
|
||||||
|
_AMBIGUOUS_SERVICES = frozenset({"", "keystone", "horizon", "simulator", "https"})
|
||||||
|
|
||||||
|
_UUID_RE = re.compile(
|
||||||
|
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
||||||
|
)
|
||||||
|
|
||||||
|
_KEYSTONE_V3_ROOTS = frozenset(
|
||||||
|
{
|
||||||
|
"auth",
|
||||||
|
"users",
|
||||||
|
"groups",
|
||||||
|
"projects",
|
||||||
|
"domains",
|
||||||
|
"roles",
|
||||||
|
"regions",
|
||||||
|
"services",
|
||||||
|
"endpoints",
|
||||||
|
"credentials",
|
||||||
|
"policies",
|
||||||
|
"role_assignments",
|
||||||
|
"OS-INHERIT",
|
||||||
|
"OS-FEDERATION",
|
||||||
|
"OS-TRUST",
|
||||||
|
"OS-EP-FILTER",
|
||||||
|
"OS-OAUTH1",
|
||||||
|
"OS-SIMPLE-CERT",
|
||||||
|
"OS-EC2",
|
||||||
|
"application_credentials",
|
||||||
|
"system",
|
||||||
|
"limits",
|
||||||
|
"registered_limits",
|
||||||
|
"project_tags",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_CINDER_V3_ROOTS = frozenset(
|
||||||
|
{
|
||||||
|
"volumes",
|
||||||
|
"snapshots",
|
||||||
|
"backups",
|
||||||
|
"types",
|
||||||
|
"qos-specs",
|
||||||
|
"groups",
|
||||||
|
"group_snapshots",
|
||||||
|
"consistencygroups",
|
||||||
|
"attachments",
|
||||||
|
"volume-transfers",
|
||||||
|
"os-services",
|
||||||
|
"os-quota-sets",
|
||||||
|
"clusters",
|
||||||
|
"messages",
|
||||||
|
"resource_filters",
|
||||||
|
"scheduler-stats",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_GLANCE_V2_ROOTS = frozenset({"images", "schemas", "metadefs", "tasks", "info"})
|
||||||
|
_MANILA_V2_ROOTS = frozenset(
|
||||||
|
{
|
||||||
|
"shares",
|
||||||
|
"snapshots",
|
||||||
|
"share-networks",
|
||||||
|
"share-servers",
|
||||||
|
"share-groups",
|
||||||
|
"security-services",
|
||||||
|
"types",
|
||||||
|
"share-replicas",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_DESIGNATE_V2_ROOTS = frozenset(
|
||||||
|
{"zones", "tlds", "blacklists", "pools", "service_statuses", "tsigkeys", "reverse"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_service_from_path(path: str) -> str | None:
|
||||||
|
"""Map an absolute OpenStack API path to a service name."""
|
||||||
|
|
||||||
|
p = (path or "/").split("?", 1)[0]
|
||||||
|
if not p.startswith("/"):
|
||||||
|
p = f"/{p}"
|
||||||
|
|
||||||
|
if p.startswith("/v2.1"):
|
||||||
|
return "nova"
|
||||||
|
if p.startswith("/v2.0"):
|
||||||
|
return "neutron"
|
||||||
|
|
||||||
|
if p.startswith("/resource_providers") or p.startswith("/resource_classes"):
|
||||||
|
return "placement"
|
||||||
|
if p.startswith("/allocation_candidates") or p.startswith("/allocations"):
|
||||||
|
return "placement"
|
||||||
|
if p.startswith("/traits") or p.startswith("/usages"):
|
||||||
|
return "placement"
|
||||||
|
|
||||||
|
if p.startswith("/v2/lbaas") or p.startswith("/v2/octavia"):
|
||||||
|
return "octavia"
|
||||||
|
|
||||||
|
if p.startswith("/v2/"):
|
||||||
|
root = p.split("/", 3)[2] if p.count("/") >= 2 else ""
|
||||||
|
if root in _GLANCE_V2_ROOTS:
|
||||||
|
return "glance"
|
||||||
|
if root in _MANILA_V2_ROOTS:
|
||||||
|
return "manila"
|
||||||
|
if root in _DESIGNATE_V2_ROOTS:
|
||||||
|
return "designate"
|
||||||
|
# Default glance for bare /v2/
|
||||||
|
return "glance"
|
||||||
|
|
||||||
|
if p.startswith("/v3"):
|
||||||
|
parts = [seg for seg in p.split("/") if seg]
|
||||||
|
if len(parts) == 1:
|
||||||
|
return "keystone"
|
||||||
|
root = parts[1]
|
||||||
|
if root in _KEYSTONE_V3_ROOTS or root.startswith("OS-"):
|
||||||
|
return "keystone"
|
||||||
|
if root in _CINDER_V3_ROOTS:
|
||||||
|
return "cinder"
|
||||||
|
# /v3/{project_id}/volumes|…
|
||||||
|
if _UUID_RE.match(root) and len(parts) >= 3 and parts[2] in _CINDER_V3_ROOTS | {"limits"}:
|
||||||
|
return "cinder"
|
||||||
|
return "keystone"
|
||||||
|
|
||||||
|
if p.startswith("/info") or p.startswith("/v1/AUTH_"):
|
||||||
|
return "swift"
|
||||||
|
if p == "/stacks" or p.startswith("/stacks"):
|
||||||
|
return "heat-cfn"
|
||||||
|
|
||||||
|
if p.startswith("/v1/"):
|
||||||
|
parts = [seg for seg in p.split("/") if seg]
|
||||||
|
root = parts[1] if len(parts) > 1 else ""
|
||||||
|
if root in {
|
||||||
|
"nodes",
|
||||||
|
"drivers",
|
||||||
|
"chassis",
|
||||||
|
"portgroups",
|
||||||
|
"conductors",
|
||||||
|
"allocations",
|
||||||
|
"deploy_templates",
|
||||||
|
}:
|
||||||
|
return "ironic"
|
||||||
|
if root in {"ports", "volume"} and (
|
||||||
|
len(parts) > 2 or root == "volume" or "portgroups" in p
|
||||||
|
):
|
||||||
|
# /v1/ports is ironic; avoid stealing neutron
|
||||||
|
return "ironic"
|
||||||
|
if root in {"secrets", "containers", "orders", "secret-stores"}:
|
||||||
|
return "barbican"
|
||||||
|
if root in {"clusters", "clustertemplates", "certificates", "mservices"}:
|
||||||
|
return "magnum"
|
||||||
|
if root in {"containers", "services", "hosts", "capsules"} and "magnum" not in root:
|
||||||
|
if root == "containers":
|
||||||
|
return "zun"
|
||||||
|
if root in {"instances", "datastores", "configurations", "backups"}:
|
||||||
|
return "trove"
|
||||||
|
if root in {"jobs", "clients", "actions", "sessions"}:
|
||||||
|
return "freezer"
|
||||||
|
if (
|
||||||
|
"stacks" in parts
|
||||||
|
or "software_configs" in parts
|
||||||
|
or "software_deployments" in parts
|
||||||
|
or "resource_types" in parts
|
||||||
|
):
|
||||||
|
return "heat"
|
||||||
|
if root.startswith("AUTH_") or (len(parts) >= 2 and parts[1].startswith("AUTH_")):
|
||||||
|
return "swift"
|
||||||
|
# Heat style /v1/{tenant}/stacks
|
||||||
|
if len(parts) >= 3 and parts[2] == "stacks":
|
||||||
|
return "heat"
|
||||||
|
if root in {"workflows", "actions", "executions", "workbooks", "cron_triggers"}:
|
||||||
|
return "mistral"
|
||||||
|
if root in {"alarms", "alarm"}:
|
||||||
|
return "aodh"
|
||||||
|
if root in {"leases", "hosts", "floatingips"}:
|
||||||
|
return "blazar"
|
||||||
|
if root in {"segments", "notifications", "hosts"}:
|
||||||
|
return "masakari"
|
||||||
|
if root in {"vnfs", "vnffgs", "vim", "nsds"}:
|
||||||
|
return "tacker"
|
||||||
|
if root in {"tasks", "tokens", "status"}:
|
||||||
|
return "adjutant"
|
||||||
|
if root in {"rating", "collect", "storage", "info"}:
|
||||||
|
return "cloudkitty"
|
||||||
|
|
||||||
|
if p.startswith("/v2/alarms") or p.startswith("/v2/query"):
|
||||||
|
return "aodh"
|
||||||
|
if p.startswith("/v2/workflows") or p.startswith("/v2/executions"):
|
||||||
|
return "mistral"
|
||||||
|
if p.startswith("/v1.0/"):
|
||||||
|
return "trove"
|
||||||
|
if p.startswith("/leases"):
|
||||||
|
return "blazar"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_service(headers: dict[str, str], path: str | None = None) -> str | None:
|
||||||
|
path_service = resolve_service_from_path(path or "") if path else None
|
||||||
|
|
||||||
|
# Identity auth must never be stolen by a stale UI route-service header.
|
||||||
|
if path and (path.startswith("/v3/auth") or path == "/v3" or path == "/v3/"):
|
||||||
|
return "keystone"
|
||||||
|
|
||||||
|
route = (headers.get("x-openstack-route-service") or "").lower().strip()
|
||||||
|
if route and route not in _AMBIGUOUS_SERVICES:
|
||||||
|
return route
|
||||||
|
|
||||||
|
header = (headers.get("x-openstack-service") or "").lower().strip()
|
||||||
|
port_raw = headers.get("x-forwarded-port") or ""
|
||||||
|
try:
|
||||||
|
port_service = _PORT_TO_SERVICE.get(int(port_raw))
|
||||||
|
except ValueError:
|
||||||
|
port_service = None
|
||||||
|
|
||||||
|
# Dedicated service ports win when path is empty or matches.
|
||||||
|
if header and header not in _AMBIGUOUS_SERVICES:
|
||||||
|
if path_service and path_service != header and header == "keystone":
|
||||||
|
return path_service
|
||||||
|
return header
|
||||||
|
|
||||||
|
if port_service and port_service not in _AMBIGUOUS_SERVICES:
|
||||||
|
if path_service and path_service != port_service and port_service == "keystone":
|
||||||
|
return path_service
|
||||||
|
return port_service
|
||||||
|
|
||||||
|
return path_service or header or port_service
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceDispatchMiddleware:
|
||||||
|
"""Pure ASGI middleware — rewrites scope['path'] before the app sees it."""
|
||||||
|
|
||||||
|
def __init__(self, app: ASGIApp) -> None:
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
if scope["type"] == "http":
|
||||||
|
path = scope.get("path") or "/"
|
||||||
|
if path == "/" or any(path.startswith(p) for p in _SKIP_PREFIXES):
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
headers = {
|
||||||
|
k.decode("latin-1").lower(): v.decode("latin-1")
|
||||||
|
for k, v in scope.get("headers") or []
|
||||||
|
}
|
||||||
|
service = resolve_service(headers, path)
|
||||||
|
if service:
|
||||||
|
scope = dict(scope)
|
||||||
|
scope["path"] = f"/_os/{service}{path}"
|
||||||
|
scope["root_path"] = scope.get("root_path") or ""
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceStateMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Expose resolved service name on request.state for handlers/microversions."""
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next) -> Response:
|
||||||
|
path = request.url.path or "/"
|
||||||
|
if path.startswith("/_os/"):
|
||||||
|
parts = path.split("/", 3)
|
||||||
|
service = parts[2] if len(parts) > 2 else None
|
||||||
|
else:
|
||||||
|
service = resolve_service(
|
||||||
|
{k.lower(): v for k, v in request.headers.items()},
|
||||||
|
path,
|
||||||
|
)
|
||||||
|
if service:
|
||||||
|
request.state.openstack_service = service
|
||||||
|
return await call_next(request)
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
"""Generic OpenStack collection/item CRUD backed by os_api_objects."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from asyncpg import Connection
|
||||||
|
from fastapi import APIRouter, Depends, Request, Response
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from app.openstack.auth import TokenContext
|
||||||
|
from app.openstack.deps import get_conn, require_project_token, require_token
|
||||||
|
from app.openstack.errors import OpenStackError
|
||||||
|
from app.openstack.surface import SERVICES, ServiceSpec
|
||||||
|
|
||||||
|
_PATH_PARAM = re.compile(r"\{([^{}]+)\}")
|
||||||
|
|
||||||
|
|
||||||
|
def _singular(collection_key: str) -> str:
|
||||||
|
if collection_key.endswith("ies"):
|
||||||
|
return collection_key[:-3] + "y"
|
||||||
|
if collection_key.endswith("ses"):
|
||||||
|
return collection_key[:-2]
|
||||||
|
if collection_key.endswith("s") and not collection_key.endswith("ss"):
|
||||||
|
return collection_key[:-1]
|
||||||
|
return collection_key
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap_list(collection_key: str, items: list[dict[str, Any]]) -> dict[str, Any]:
|
||||||
|
return {collection_key: items}
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap_item(collection_key: str, item: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {_singular(collection_key): item}
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_item(row: Any) -> dict[str, Any]:
|
||||||
|
data = row["data"]
|
||||||
|
if isinstance(data, str):
|
||||||
|
data = json.loads(data)
|
||||||
|
item = dict(data or {})
|
||||||
|
item.setdefault("id", str(row["id"]))
|
||||||
|
item.setdefault("name", row["name"])
|
||||||
|
item.setdefault("status", row["status"])
|
||||||
|
if row["project_id"] is not None:
|
||||||
|
item.setdefault("project_id", str(row["project_id"]))
|
||||||
|
item.setdefault("tenant_id", str(row["project_id"]))
|
||||||
|
item.setdefault("created_at", row["created_at"].strftime("%Y-%m-%dT%H:%M:%SZ"))
|
||||||
|
item.setdefault("updated_at", row["updated_at"].strftime("%Y-%m-%dT%H:%M:%SZ"))
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def _has_path_params(path: str) -> bool:
|
||||||
|
return bool(_PATH_PARAM.search(path))
|
||||||
|
|
||||||
|
|
||||||
|
def build_generic_router(spec: ServiceSpec) -> APIRouter:
|
||||||
|
router = APIRouter(tags=[spec.name.title()])
|
||||||
|
|
||||||
|
version_path = (spec.version_path or "").rstrip("/")
|
||||||
|
if version_path and version_path != "/":
|
||||||
|
service_name = spec.name
|
||||||
|
|
||||||
|
async def version_discovery(
|
||||||
|
conn: Connection = Depends(get_conn),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
from app.openstack.db_docs import require_doc
|
||||||
|
|
||||||
|
return await require_doc(
|
||||||
|
conn, service=service_name, resource_type="discovery_version", name="default"
|
||||||
|
)
|
||||||
|
|
||||||
|
router.add_api_route(
|
||||||
|
version_path,
|
||||||
|
version_discovery,
|
||||||
|
methods=["GET"],
|
||||||
|
name=f"gen-{spec.name}-version",
|
||||||
|
)
|
||||||
|
router.add_api_route(
|
||||||
|
version_path + "/",
|
||||||
|
version_discovery,
|
||||||
|
methods=["GET"],
|
||||||
|
name=f"gen-{spec.name}-version-slash",
|
||||||
|
)
|
||||||
|
|
||||||
|
for resource_type, collection_path, collection_key in spec.resources:
|
||||||
|
if collection_path in {"", "/"} or _has_path_params(collection_path):
|
||||||
|
# Nested templates / bare roots need specialized routers.
|
||||||
|
continue
|
||||||
|
_register_collection(
|
||||||
|
router,
|
||||||
|
spec=spec,
|
||||||
|
resource_type=resource_type,
|
||||||
|
collection_path=collection_path,
|
||||||
|
collection_key=collection_key,
|
||||||
|
)
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
def _register_collection(
|
||||||
|
router: APIRouter,
|
||||||
|
*,
|
||||||
|
spec: ServiceSpec,
|
||||||
|
resource_type: str,
|
||||||
|
collection_path: str,
|
||||||
|
collection_key: str,
|
||||||
|
) -> None:
|
||||||
|
item_path = f"{collection_path.rstrip('/')}/{{item_id}}"
|
||||||
|
|
||||||
|
async def list_items(
|
||||||
|
conn: Connection = Depends(get_conn),
|
||||||
|
ctx: TokenContext = Depends(require_token),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if ctx.project_id is None:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""SELECT * FROM os_api_objects
|
||||||
|
WHERE service = $1 AND resource_type = $2
|
||||||
|
ORDER BY created_at""",
|
||||||
|
spec.name,
|
||||||
|
resource_type,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""SELECT * FROM os_api_objects
|
||||||
|
WHERE service = $1 AND resource_type = $2
|
||||||
|
AND (project_id = $3 OR project_id IS NULL)
|
||||||
|
ORDER BY created_at""",
|
||||||
|
spec.name,
|
||||||
|
resource_type,
|
||||||
|
ctx.project_id,
|
||||||
|
)
|
||||||
|
return _wrap_list(collection_key, [_row_to_item(r) for r in rows])
|
||||||
|
|
||||||
|
async def create_item(
|
||||||
|
request: Request,
|
||||||
|
conn: Connection = Depends(get_conn),
|
||||||
|
ctx: TokenContext = Depends(require_project_token),
|
||||||
|
) -> JSONResponse:
|
||||||
|
payload = await request.json()
|
||||||
|
body = payload.get(_singular(collection_key)) or payload.get(collection_key) or payload
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
body = {"value": body}
|
||||||
|
item_id = uuid4()
|
||||||
|
name = str(body.get("name") or body.get("stack_name") or resource_type)
|
||||||
|
status = str(body.get("status") or body.get("stack_status") or "ACTIVE")
|
||||||
|
data = {**body, "id": str(item_id), "name": name, "status": status}
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""INSERT INTO os_api_objects(id, service, resource_type, project_id, name, status, data)
|
||||||
|
VALUES($1, $2, $3, $4, $5, $6, $7::jsonb)
|
||||||
|
RETURNING *""",
|
||||||
|
item_id,
|
||||||
|
spec.name,
|
||||||
|
resource_type,
|
||||||
|
ctx.project_id,
|
||||||
|
name,
|
||||||
|
status,
|
||||||
|
json.dumps(data),
|
||||||
|
)
|
||||||
|
return JSONResponse(status_code=201, content=_wrap_item(collection_key, _row_to_item(row)))
|
||||||
|
|
||||||
|
async def show_item(
|
||||||
|
item_id: str,
|
||||||
|
conn: Connection = Depends(get_conn),
|
||||||
|
ctx: TokenContext = Depends(require_token),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""SELECT * FROM os_api_objects
|
||||||
|
WHERE service = $1 AND resource_type = $2 AND id::text = $3""",
|
||||||
|
spec.name,
|
||||||
|
resource_type,
|
||||||
|
item_id,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
# also allow name lookup
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""SELECT * FROM os_api_objects
|
||||||
|
WHERE service = $1 AND resource_type = $2 AND name = $3
|
||||||
|
LIMIT 1""",
|
||||||
|
spec.name,
|
||||||
|
resource_type,
|
||||||
|
item_id,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise OpenStackError(
|
||||||
|
"NotFound", f"{resource_type} {item_id} not found", status_code=404
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
ctx.project_id is not None
|
||||||
|
and row["project_id"] is not None
|
||||||
|
and row["project_id"] != ctx.project_id
|
||||||
|
and not ctx.is_admin
|
||||||
|
):
|
||||||
|
raise OpenStackError(
|
||||||
|
"NotFound", f"{resource_type} {item_id} not found", status_code=404
|
||||||
|
)
|
||||||
|
return _wrap_item(collection_key, _row_to_item(row))
|
||||||
|
|
||||||
|
async def update_item(
|
||||||
|
item_id: str,
|
||||||
|
request: Request,
|
||||||
|
conn: Connection = Depends(get_conn),
|
||||||
|
ctx: TokenContext = Depends(require_project_token),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
payload = await request.json()
|
||||||
|
body = payload.get(_singular(collection_key), payload)
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
raise OpenStackError("BadRequest", "JSON object required", status_code=400)
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""SELECT * FROM os_api_objects
|
||||||
|
WHERE service = $1 AND resource_type = $2 AND id::text = $3 AND project_id = $4""",
|
||||||
|
spec.name,
|
||||||
|
resource_type,
|
||||||
|
item_id,
|
||||||
|
ctx.project_id,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise OpenStackError(
|
||||||
|
"NotFound", f"{resource_type} {item_id} not found", status_code=404
|
||||||
|
)
|
||||||
|
data = row["data"]
|
||||||
|
if isinstance(data, str):
|
||||||
|
data = json.loads(data)
|
||||||
|
data = {**(data or {}), **body, "id": str(row["id"])}
|
||||||
|
name = str(data.get("name") or row["name"])
|
||||||
|
status = str(data.get("status") or row["status"])
|
||||||
|
updated = await conn.fetchrow(
|
||||||
|
"""UPDATE os_api_objects
|
||||||
|
SET name = $1, status = $2, data = $3::jsonb, updated_at = now()
|
||||||
|
WHERE id = $4
|
||||||
|
RETURNING *""",
|
||||||
|
name,
|
||||||
|
status,
|
||||||
|
json.dumps(data),
|
||||||
|
row["id"],
|
||||||
|
)
|
||||||
|
return _wrap_item(collection_key, _row_to_item(updated))
|
||||||
|
|
||||||
|
async def delete_item(
|
||||||
|
item_id: str,
|
||||||
|
conn: Connection = Depends(get_conn),
|
||||||
|
ctx: TokenContext = Depends(require_project_token),
|
||||||
|
) -> Response:
|
||||||
|
result = await conn.execute(
|
||||||
|
"""DELETE FROM os_api_objects
|
||||||
|
WHERE service = $1 AND resource_type = $2 AND id::text = $3 AND project_id = $4""",
|
||||||
|
spec.name,
|
||||||
|
resource_type,
|
||||||
|
item_id,
|
||||||
|
ctx.project_id,
|
||||||
|
)
|
||||||
|
if result.endswith("0"):
|
||||||
|
raise OpenStackError(
|
||||||
|
"NotFound", f"{resource_type} {item_id} not found", status_code=404
|
||||||
|
)
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
# Bind with defaults to capture loop variables.
|
||||||
|
router.add_api_route(
|
||||||
|
collection_path, list_items, methods=["GET"], name=f"gen-{spec.name}-{resource_type}-list"
|
||||||
|
)
|
||||||
|
router.add_api_route(
|
||||||
|
collection_path,
|
||||||
|
create_item,
|
||||||
|
methods=["POST"],
|
||||||
|
name=f"gen-{spec.name}-{resource_type}-create",
|
||||||
|
)
|
||||||
|
router.add_api_route(
|
||||||
|
item_path, show_item, methods=["GET"], name=f"gen-{spec.name}-{resource_type}-show"
|
||||||
|
)
|
||||||
|
router.add_api_route(
|
||||||
|
item_path,
|
||||||
|
update_item,
|
||||||
|
methods=["PUT", "PATCH"],
|
||||||
|
name=f"gen-{spec.name}-{resource_type}-update",
|
||||||
|
)
|
||||||
|
router.add_api_route(
|
||||||
|
item_path, delete_item, methods=["DELETE"], name=f"gen-{spec.name}-{resource_type}-delete"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def mount_generic_services(app: Any, *, skip: set[str] | None = None) -> int:
|
||||||
|
"""Mount generic routers under /_os/<service> for every service not in skip."""
|
||||||
|
|
||||||
|
skipped = skip or set()
|
||||||
|
count = 0
|
||||||
|
for spec in SERVICES:
|
||||||
|
if spec.name in skipped:
|
||||||
|
continue
|
||||||
|
app.include_router(build_generic_router(spec), prefix=f"/_os/{spec.name}")
|
||||||
|
count += 1 + sum(
|
||||||
|
1
|
||||||
|
for _, path, _ in spec.resources
|
||||||
|
if path not in {"", "/"} and not _has_path_params(path)
|
||||||
|
)
|
||||||
|
return count
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""OpenStack-style error responses."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
|
||||||
|
class OpenStackError(Exception):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
code: str,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
status_code: int = 400,
|
||||||
|
title: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code
|
||||||
|
self.message = message
|
||||||
|
self.status_code = status_code
|
||||||
|
self.title = title or code
|
||||||
|
|
||||||
|
|
||||||
|
async def openstack_error_handler(_request: Request, exc: OpenStackError) -> JSONResponse:
|
||||||
|
# Neutron/Glance often use {"NeutronError": ...} etc.; use a common envelope
|
||||||
|
# that openstacksdk accepts for generic HTTP errors, plus itemized faults.
|
||||||
|
body: dict[str, object]
|
||||||
|
if exc.status_code in {401, 403}:
|
||||||
|
body = {
|
||||||
|
"error": {
|
||||||
|
"code": exc.status_code,
|
||||||
|
"title": exc.title,
|
||||||
|
"message": exc.message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elif "Compute" in exc.code or exc.code.startswith("compute"):
|
||||||
|
body = {
|
||||||
|
"itemNotFound" if exc.status_code == 404 else "badRequest": {
|
||||||
|
"code": exc.status_code,
|
||||||
|
"message": exc.message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
body = {
|
||||||
|
"error": {
|
||||||
|
"code": exc.status_code,
|
||||||
|
"title": exc.title,
|
||||||
|
"message": exc.message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return JSONResponse(status_code=exc.status_code, content=body)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"""Stable UUIDs for seeded OpenStack entities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
NAMESPACE = uuid.UUID("7e2c0f9a-4b11-4d6e-9c3a-0a0b0c0d0e0f")
|
||||||
|
|
||||||
|
|
||||||
|
def oid(name: str) -> uuid.UUID:
|
||||||
|
return uuid.uuid5(NAMESPACE, name)
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""OpenStack API microversion middleware (Nova / Cinder / Manila style)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
# Fallback service -> (default, max) when contract pack is not loaded.
|
||||||
|
MICROVERSIONS: dict[str, tuple[str, str]] = {
|
||||||
|
"nova": ("2.1", "2.96"),
|
||||||
|
"cinder": ("3.0", "3.70"),
|
||||||
|
"manila": ("2.0", "2.82"),
|
||||||
|
"ironic": ("1.1", "1.90"),
|
||||||
|
"placement": ("1.0", "1.39"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _service_from_request(request: Request) -> str | None:
|
||||||
|
header = request.headers.get("x-openstack-service")
|
||||||
|
if header:
|
||||||
|
return header.lower()
|
||||||
|
port = request.headers.get("x-forwarded-port")
|
||||||
|
port_map = {
|
||||||
|
"8774": "nova",
|
||||||
|
"8776": "cinder",
|
||||||
|
"8786": "manila",
|
||||||
|
"6385": "ironic",
|
||||||
|
"8003": "placement",
|
||||||
|
}
|
||||||
|
return port_map.get(str(port))
|
||||||
|
|
||||||
|
|
||||||
|
def _bounds(service: str) -> tuple[str, str] | None:
|
||||||
|
try:
|
||||||
|
from app.openstack.contract_loader import get_runtime
|
||||||
|
|
||||||
|
runtime = get_runtime()
|
||||||
|
pack = runtime.packs.get(service)
|
||||||
|
if pack and pack.default_microversion and pack.max_microversion:
|
||||||
|
return pack.default_microversion, pack.max_microversion
|
||||||
|
override = runtime.active_microversion(service)
|
||||||
|
if pack and override and pack.max_microversion:
|
||||||
|
return override, pack.max_microversion
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return MICROVERSIONS.get(service)
|
||||||
|
|
||||||
|
|
||||||
|
class MicroversionMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request: Request, call_next) -> Response:
|
||||||
|
service = _service_from_request(request)
|
||||||
|
requested = request.headers.get("openstack-api-version") or request.headers.get(
|
||||||
|
"x-openstack-nova-api-version"
|
||||||
|
)
|
||||||
|
version = None
|
||||||
|
if requested:
|
||||||
|
parts = requested.strip().split()
|
||||||
|
version = parts[-1] if parts else None
|
||||||
|
bounds = _bounds(service) if service else None
|
||||||
|
if service and bounds:
|
||||||
|
default, maximum = bounds
|
||||||
|
try:
|
||||||
|
from app.openstack.contract_loader import get_runtime
|
||||||
|
|
||||||
|
custom = get_runtime().microversion_overrides.get(service)
|
||||||
|
except Exception:
|
||||||
|
custom = None
|
||||||
|
chosen = version or custom or default
|
||||||
|
request.state.microversion = chosen
|
||||||
|
request.state.microversion_max = maximum
|
||||||
|
request.state.microversion_service = service
|
||||||
|
response = await call_next(request)
|
||||||
|
if service and bounds:
|
||||||
|
default, maximum = bounds
|
||||||
|
chosen = getattr(request.state, "microversion", default)
|
||||||
|
if service == "nova":
|
||||||
|
response.headers["OpenStack-API-Version"] = f"compute {chosen}"
|
||||||
|
response.headers["X-OpenStack-Nova-API-Version"] = chosen
|
||||||
|
elif service == "cinder":
|
||||||
|
response.headers["OpenStack-API-Version"] = f"volume {chosen}"
|
||||||
|
elif service == "placement":
|
||||||
|
response.headers["OpenStack-API-Version"] = f"placement {chosen}"
|
||||||
|
else:
|
||||||
|
response.headers["OpenStack-API-Version"] = f"{service} {chosen}"
|
||||||
|
response.headers.setdefault("Vary", "OpenStack-API-Version")
|
||||||
|
return response
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Mount OpenStack service routers onto the FastAPI application."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from app.openstack.contract_loader import ensure_loaded, load_series_pack
|
||||||
|
from app.openstack.dispatch import ServiceDispatchMiddleware, ServiceStateMiddleware
|
||||||
|
from app.openstack.engine import mount_generic_services
|
||||||
|
from app.openstack.errors import OpenStackError, openstack_error_handler
|
||||||
|
from app.openstack.microversions import MicroversionMiddleware
|
||||||
|
from app.openstack.registry import HandlerRegistry, register_specialized_handlers
|
||||||
|
from app.openstack.routes import (
|
||||||
|
cinder,
|
||||||
|
glance,
|
||||||
|
heat,
|
||||||
|
ironic,
|
||||||
|
keystone,
|
||||||
|
neutron,
|
||||||
|
nova,
|
||||||
|
octavia,
|
||||||
|
placement,
|
||||||
|
root,
|
||||||
|
swift,
|
||||||
|
)
|
||||||
|
from app.openstack.schema_engine import mount_schema_services
|
||||||
|
|
||||||
|
|
||||||
|
# Specialized routers provide stateful handlers; contract packs register every
|
||||||
|
# path. Legacy generic engine remains as a final fallback for undeclared services.
|
||||||
|
_SPECIALIZED_ROUTERS: list[tuple[str, object]] = [
|
||||||
|
("keystone", keystone.router),
|
||||||
|
("nova", nova.router),
|
||||||
|
("neutron", neutron.router),
|
||||||
|
("glance", glance.router),
|
||||||
|
("cinder", cinder.router),
|
||||||
|
("placement", placement.router),
|
||||||
|
("heat", heat.router),
|
||||||
|
("swift", swift.router),
|
||||||
|
("ironic", ironic.router),
|
||||||
|
("octavia", octavia.router),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_openstack_handlers() -> HandlerRegistry:
|
||||||
|
"""Collect stateful handlers from specialized routers into a registry."""
|
||||||
|
|
||||||
|
registry = HandlerRegistry()
|
||||||
|
for name, router in _SPECIALIZED_ROUTERS:
|
||||||
|
register_specialized_handlers(registry, name, router) # type: ignore[arg-type]
|
||||||
|
return registry
|
||||||
|
|
||||||
|
|
||||||
|
def mount_openstack_routes(app: FastAPI, *, series: str = "dalmatian") -> None:
|
||||||
|
"""Register all OpenStack Identity + IaaS + schema-complete APIs."""
|
||||||
|
|
||||||
|
app.add_exception_handler(OpenStackError, openstack_error_handler)
|
||||||
|
|
||||||
|
# Order matters: last added = outermost. Dispatch must be outermost so
|
||||||
|
# rewritten paths reach routers; microversions see original headers.
|
||||||
|
app.add_middleware(MicroversionMiddleware)
|
||||||
|
app.add_middleware(ServiceStateMiddleware)
|
||||||
|
app.add_middleware(ServiceDispatchMiddleware)
|
||||||
|
|
||||||
|
# Port-aware version discovery stays on bare "/".
|
||||||
|
app.include_router(root.router)
|
||||||
|
|
||||||
|
# Contract is the sole path source; specialized routers contribute handlers only.
|
||||||
|
handlers = build_openstack_handlers()
|
||||||
|
ensure_loaded(series)
|
||||||
|
mounted = mount_schema_services(app, series=series, handlers=handlers)
|
||||||
|
app.state.openstack_schema_ops = mounted
|
||||||
|
app.state.openstack_handlers = handlers
|
||||||
|
|
||||||
|
# Legacy generic CRUD only for services without a schema pack (avoid
|
||||||
|
# /{item_id} stealing /detail and other static schema paths).
|
||||||
|
schema_services = set(load_series_pack(series).keys())
|
||||||
|
mount_generic_services(app, skip=schema_services)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""OperationSpec — declarative OpenStack API operation descriptor."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OperationSpec:
|
||||||
|
"""One OpenStack API operation (method + path) within a service pack."""
|
||||||
|
|
||||||
|
operation_id: str
|
||||||
|
method: HttpMethod
|
||||||
|
path: str
|
||||||
|
service: str
|
||||||
|
resource_type: str
|
||||||
|
collection_key: str | None = None
|
||||||
|
item_key: str | None = None
|
||||||
|
kind: Literal["collection", "item", "action", "detail", "custom"] = "collection"
|
||||||
|
status_code: int = 200
|
||||||
|
create_status: int = 201
|
||||||
|
microversion_min: str | None = None
|
||||||
|
microversion_max: str | None = None
|
||||||
|
requires_auth: bool = True
|
||||||
|
requires_project: bool = True
|
||||||
|
action_name: str | None = None
|
||||||
|
response_fixture: dict[str, Any] | None = None
|
||||||
|
notes: str = ""
|
||||||
|
|
||||||
|
def path_params(self) -> list[str]:
|
||||||
|
import re
|
||||||
|
|
||||||
|
return re.findall(r"\{([^{}]+)\}", self.path)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ServicePack:
|
||||||
|
name: str
|
||||||
|
typ: str
|
||||||
|
port: int
|
||||||
|
version_path: str
|
||||||
|
default_microversion: str | None
|
||||||
|
max_microversion: str | None
|
||||||
|
operations: list[OperationSpec] = field(default_factory=list)
|
||||||
|
|
||||||
|
def operation_count(self) -> int:
|
||||||
|
return len(self.operations)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SeriesManifest:
|
||||||
|
series: str
|
||||||
|
major: int
|
||||||
|
services: list[dict[str, Any]]
|
||||||
|
checksum: str = ""
|
||||||
|
generated_at: str = ""
|
||||||
|
operation_count: int = 0
|
||||||
|
service_count: int = 0
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""Seed ``os_api_objects`` rows for every contract pack resource_type.
|
||||||
|
|
||||||
|
Ensures list GETs across all OpenStack series have persistent DB rows
|
||||||
|
(so list/show handlers serve PostgreSQL rows only).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from asyncpg import Connection
|
||||||
|
|
||||||
|
from app.openstack.contract_loader import list_series, load_series_pack
|
||||||
|
from app.openstack.ids import oid
|
||||||
|
|
||||||
|
|
||||||
|
def _default_payload(service: str, resource_type: str, name: str, index: int) -> dict[str, Any]:
|
||||||
|
"""Reasonable lab JSON per resource family."""
|
||||||
|
|
||||||
|
base: dict[str, Any] = {
|
||||||
|
"name": name,
|
||||||
|
"status": "ACTIVE",
|
||||||
|
"enabled": True,
|
||||||
|
"description": f"lab {service} {resource_type} {index}",
|
||||||
|
"index": index,
|
||||||
|
}
|
||||||
|
# Light resource-specific hints for common clients.
|
||||||
|
if resource_type in {"share", "volume", "backup", "share_snapshot"}:
|
||||||
|
base.update({"size": 10, "status": "available"})
|
||||||
|
elif resource_type in {"zone", "tld"}:
|
||||||
|
base.update({"email": "hostmaster@lab.example", "ttl": 3600, "type": "PRIMARY"})
|
||||||
|
elif resource_type in {"alarm"}:
|
||||||
|
base.update({"type": "threshold", "state": "ok"})
|
||||||
|
elif resource_type in {"queue"}:
|
||||||
|
base.update({"_default_message_ttl": 3600})
|
||||||
|
elif resource_type in {"cluster"}:
|
||||||
|
base.update({"coe": "kubernetes", "status": "CREATE_COMPLETE", "node_count": 2})
|
||||||
|
elif resource_type in {"container"} and service == "zun":
|
||||||
|
base.update({"image": "cirros", "status": "Running"})
|
||||||
|
elif resource_type in {"container"} and service == "barbican":
|
||||||
|
base.update({"type": "generic", "status": "ACTIVE"})
|
||||||
|
elif resource_type in {"secret"}:
|
||||||
|
base.update({"secret_type": "passphrase", "payload_content_type": "text/plain"})
|
||||||
|
elif resource_type in {"datastore"}:
|
||||||
|
base.update({"type": "mysql", "version": "8.0"})
|
||||||
|
elif resource_type in {"instance"} and service == "trove":
|
||||||
|
base.update({"datastore": {"type": "mysql", "version": "8.0"}, "status": "ACTIVE"})
|
||||||
|
elif resource_type in {"workflow", "workbook"}:
|
||||||
|
base.update({"definition": "version: '2.0'\ndemo:\n tasks: {}"})
|
||||||
|
elif resource_type in {"dataframes"}:
|
||||||
|
base.update({"period": "3600"})
|
||||||
|
elif resource_type in {"quota", "quota_set"}:
|
||||||
|
base.update({"limit": 100, "in_use": index})
|
||||||
|
elif resource_type in {"status", "service_status", "health"}:
|
||||||
|
base.update({"status": "UP", "state": "up", "service": service})
|
||||||
|
elif resource_type == "ping":
|
||||||
|
base.update({"ping": "pong", "ok": True})
|
||||||
|
elif resource_type == "driver" and service == "ironic":
|
||||||
|
base.update({"hosts": ["simulator"], "type": "classic"})
|
||||||
|
elif resource_type == "agent" and service == "neutron":
|
||||||
|
base.update(
|
||||||
|
{
|
||||||
|
"agent_type": "L3 agent",
|
||||||
|
"host": f"network-{index}",
|
||||||
|
"alive": True,
|
||||||
|
"admin_state_up": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif resource_type == "console_output":
|
||||||
|
base.update({"output": "Booting...\nSimulator console\n"})
|
||||||
|
elif resource_type == "console":
|
||||||
|
base.update(
|
||||||
|
{"type": "novnc", "url": "https://127.0.0.1:6080/vnc_auto.html?token=simulator"}
|
||||||
|
)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def iter_pack_resource_types(*, series: str | None = None) -> set[tuple[str, str]]:
|
||||||
|
"""Return ``{(service, resource_type)}`` declared by GET collection/detail/custom ops."""
|
||||||
|
|
||||||
|
series_names = [series] if series else [str(item["series"]) for item in list_series()]
|
||||||
|
found: set[tuple[str, str]] = set()
|
||||||
|
for name in series_names:
|
||||||
|
packs = load_series_pack(name)
|
||||||
|
for pack in packs.values():
|
||||||
|
for op in pack.operations:
|
||||||
|
if op.method != "GET":
|
||||||
|
continue
|
||||||
|
if op.kind not in {"collection", "detail", "custom"}:
|
||||||
|
continue
|
||||||
|
if not op.resource_type or op.resource_type in {"version", "ping"}:
|
||||||
|
continue
|
||||||
|
found.add((pack.name, op.resource_type))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
async def seed_pack_surface_samples(
|
||||||
|
conn: Connection,
|
||||||
|
*,
|
||||||
|
series: str | None = None,
|
||||||
|
per_type: int = 3,
|
||||||
|
project_id: Any | None = None,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
"""Insert lab rows for every pack resource_type (idempotent via stable oid)."""
|
||||||
|
|
||||||
|
types = iter_pack_resource_types(series=series)
|
||||||
|
inserted = 0
|
||||||
|
for service, resource_type in sorted(types):
|
||||||
|
for index in range(per_type):
|
||||||
|
name = f"{resource_type}-{index}"
|
||||||
|
item_id = oid(f"packseed:{service}:{resource_type}:{name}")
|
||||||
|
payload = _default_payload(service, resource_type, name, index)
|
||||||
|
payload["id"] = str(item_id)
|
||||||
|
status = str(payload.get("status") or "ACTIVE")
|
||||||
|
result = await conn.execute(
|
||||||
|
"""INSERT INTO os_api_objects(id, service, resource_type, project_id, name, status, data)
|
||||||
|
VALUES($1,$2,$3,$4,$5,$6,$7::jsonb)
|
||||||
|
ON CONFLICT (id) DO NOTHING""",
|
||||||
|
item_id,
|
||||||
|
service,
|
||||||
|
resource_type,
|
||||||
|
project_id,
|
||||||
|
name,
|
||||||
|
status,
|
||||||
|
json.dumps(payload),
|
||||||
|
)
|
||||||
|
# asyncpg: "INSERT 0 1" on insert, "INSERT 0 0" on conflict skip
|
||||||
|
if result.split()[-1] == "1":
|
||||||
|
inserted += 1
|
||||||
|
return {"resource_types": len(types), "rows_inserted": inserted, "per_type": per_type}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""OpenStack-style limit/marker pagination helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
|
||||||
|
def parse_limit(request: Request, *, default: int = 0, maximum: int = 1000) -> int:
|
||||||
|
raw = request.query_params.get("limit")
|
||||||
|
if raw is None or raw == "":
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
value = int(raw)
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
if value <= 0:
|
||||||
|
return default
|
||||||
|
return min(value, maximum)
|
||||||
|
|
||||||
|
|
||||||
|
def paginate_rows(
|
||||||
|
rows: list[Any],
|
||||||
|
request: Request,
|
||||||
|
*,
|
||||||
|
id_attr: Callable[[Any], str],
|
||||||
|
default_limit: int = 0,
|
||||||
|
) -> tuple[list[Any], list[dict[str, str]]]:
|
||||||
|
"""Slice rows by marker/limit. Returns (page, link dicts for next)."""
|
||||||
|
|
||||||
|
marker = request.query_params.get("marker")
|
||||||
|
limit = parse_limit(request, default=default_limit)
|
||||||
|
start = 0
|
||||||
|
if marker:
|
||||||
|
for index, row in enumerate(rows):
|
||||||
|
if id_attr(row) == marker:
|
||||||
|
start = index + 1
|
||||||
|
break
|
||||||
|
page = rows[start:]
|
||||||
|
links: list[dict[str, str]] = []
|
||||||
|
if limit > 0 and len(page) > limit:
|
||||||
|
page = page[:limit]
|
||||||
|
last = page[-1]
|
||||||
|
links.append({"rel": "next", "href": f"?marker={id_attr(last)}&limit={limit}"})
|
||||||
|
return page, links
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
"""Contract-driven OpenStack route registry (Proxmox-style per-path registration)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request, Response
|
||||||
|
from fastapi.routing import APIRoute, APIRouter
|
||||||
|
|
||||||
|
from app.api.openapi import service_openapi_tag
|
||||||
|
from app.openstack.opspec import OperationSpec, ServicePack
|
||||||
|
|
||||||
|
Handler = Callable[[Request], Awaitable[Response]]
|
||||||
|
|
||||||
|
_PATH_PARAM = re.compile(r"\{([^{}]+)\}")
|
||||||
|
_ROUTE_NAME_PREFIX = "os-contract:"
|
||||||
|
|
||||||
|
|
||||||
|
class RouteCollisionError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_path_template(path: str) -> str:
|
||||||
|
"""Collapse `{param}` names so `/servers/{id}` matches `/servers/{server_id}`."""
|
||||||
|
|
||||||
|
return _PATH_PARAM.sub("{}", path if path.startswith("/") else f"/{path}")
|
||||||
|
|
||||||
|
|
||||||
|
def _param_names(path: str) -> list[str]:
|
||||||
|
"""Path param names without FastAPI converters (``{object_name:path}`` → ``object_name``)."""
|
||||||
|
|
||||||
|
return [name.split(":", 1)[0] for name in _PATH_PARAM.findall(path)]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class HandlerRegistry:
|
||||||
|
"""Semantic handlers keyed by (service, path, verb)."""
|
||||||
|
|
||||||
|
_handlers: dict[tuple[str, str, str], Handler] = field(default_factory=dict)
|
||||||
|
_normalized: dict[tuple[str, str, str], tuple[str, Handler]] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def register(self, service: str, path: str, verb: str, handler: Handler) -> None:
|
||||||
|
key = (service, path, verb.upper())
|
||||||
|
if key in self._handlers:
|
||||||
|
raise RouteCollisionError(f"duplicate semantic handler: {verb} {service} {path}")
|
||||||
|
self._handlers[key] = handler
|
||||||
|
norm_key = (service, normalize_path_template(path), verb.upper())
|
||||||
|
# First registration wins for structural lookup (prefer exact contract names).
|
||||||
|
self._normalized.setdefault(norm_key, (path, handler))
|
||||||
|
|
||||||
|
def get(self, service: str, path: str, verb: str) -> Handler | None:
|
||||||
|
verb_u = verb.upper()
|
||||||
|
exact = self._handlers.get((service, path, verb_u))
|
||||||
|
if exact is not None:
|
||||||
|
return exact
|
||||||
|
hit = self._normalized.get((service, normalize_path_template(path), verb_u))
|
||||||
|
return hit[1] if hit else None
|
||||||
|
|
||||||
|
def get_specialized_path(self, service: str, path: str, verb: str) -> str | None:
|
||||||
|
"""Return the path template the handler was registered under (for param remap)."""
|
||||||
|
|
||||||
|
verb_u = verb.upper()
|
||||||
|
if (service, path, verb_u) in self._handlers:
|
||||||
|
return path
|
||||||
|
hit = self._normalized.get((service, normalize_path_template(path), verb_u))
|
||||||
|
return hit[0] if hit else None
|
||||||
|
|
||||||
|
def keys(self) -> frozenset[tuple[str, str, str]]:
|
||||||
|
return frozenset(self._handlers)
|
||||||
|
|
||||||
|
|
||||||
|
def _fastapi_path(path: str) -> str:
|
||||||
|
return path if path.startswith("/") else f"/{path}"
|
||||||
|
|
||||||
|
|
||||||
|
def _route_priority(op: OperationSpec) -> tuple[int, int, str]:
|
||||||
|
"""Static paths before templated ones so /detail is not captured by /{id}."""
|
||||||
|
|
||||||
|
path = op.path
|
||||||
|
braces = path.count("{")
|
||||||
|
detail_bias = 0 if path.rstrip("/").endswith("/detail") else 1
|
||||||
|
return (braces, detail_bias, path)
|
||||||
|
|
||||||
|
|
||||||
|
def _remap_path_params(request: Request, contract_path: str, specialized_path: str) -> None:
|
||||||
|
"""Align request.path_params names with the specialized route template."""
|
||||||
|
|
||||||
|
contract_names = _param_names(contract_path)
|
||||||
|
specialized_names = _param_names(specialized_path)
|
||||||
|
if not specialized_names:
|
||||||
|
return
|
||||||
|
current = dict(request.path_params)
|
||||||
|
if set(specialized_names) <= set(current):
|
||||||
|
return
|
||||||
|
values: list[str] = []
|
||||||
|
for name in contract_names:
|
||||||
|
if name in current:
|
||||||
|
values.append(str(current[name]))
|
||||||
|
if len(values) != len(specialized_names):
|
||||||
|
# Fall back to positional values already present.
|
||||||
|
values = [str(v) for v in current.values()]
|
||||||
|
if len(values) != len(specialized_names):
|
||||||
|
return
|
||||||
|
remapped = dict(zip(specialized_names, values, strict=True))
|
||||||
|
# Keep any non-path extras (unlikely) under original keys.
|
||||||
|
for key, value in current.items():
|
||||||
|
if key not in remapped and key not in contract_names:
|
||||||
|
remapped[key] = value
|
||||||
|
request.scope["path_params"] = remapped
|
||||||
|
|
||||||
|
|
||||||
|
def _bridge_route_handler(specialized_path: str, route_handler: Handler) -> Handler:
|
||||||
|
async def handler(request: Request) -> Response:
|
||||||
|
contract_path = getattr(request.state, "os_contract_path", specialized_path)
|
||||||
|
_remap_path_params(request, contract_path, specialized_path)
|
||||||
|
return await route_handler(request)
|
||||||
|
|
||||||
|
return handler
|
||||||
|
|
||||||
|
|
||||||
|
def register_specialized_handlers(
|
||||||
|
registry: HandlerRegistry,
|
||||||
|
service: str,
|
||||||
|
router: APIRouter,
|
||||||
|
) -> int:
|
||||||
|
"""Import FastAPI router endpoints into the semantic handler registry."""
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for route in router.routes:
|
||||||
|
if not isinstance(route, APIRoute):
|
||||||
|
continue
|
||||||
|
methods = route.methods or set()
|
||||||
|
route_handler = route.get_route_handler()
|
||||||
|
for method in methods:
|
||||||
|
if method in {"HEAD", "OPTIONS"}:
|
||||||
|
continue
|
||||||
|
path = route.path
|
||||||
|
key = (service, path, method.upper())
|
||||||
|
if key in registry._handlers:
|
||||||
|
continue
|
||||||
|
registry.register(
|
||||||
|
service,
|
||||||
|
path,
|
||||||
|
method,
|
||||||
|
_bridge_route_handler(path, route_handler),
|
||||||
|
)
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def clear_os_contract_routes(app: FastAPI) -> None:
|
||||||
|
"""Drop previously registered ``os-contract:`` routes for rebuild / hot-swap."""
|
||||||
|
|
||||||
|
app.router.routes = [
|
||||||
|
route
|
||||||
|
for route in app.router.routes
|
||||||
|
if not (
|
||||||
|
isinstance(getattr(route, "name", None), str)
|
||||||
|
and str(route.name).startswith(_ROUTE_NAME_PREFIX)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
app.openapi_schema = None
|
||||||
|
|
||||||
|
|
||||||
|
def register_openstack_contract_routes(
|
||||||
|
app: FastAPI,
|
||||||
|
packs: dict[str, ServicePack],
|
||||||
|
handlers: HandlerRegistry,
|
||||||
|
*,
|
||||||
|
dispatch_fn: Callable[[Request, ServicePack, OperationSpec], Awaitable[Response]],
|
||||||
|
) -> int:
|
||||||
|
"""Register one FastAPI route per unique (service, method, path) from packs.
|
||||||
|
|
||||||
|
Endpoint looks up a semantic handler first; otherwise falls back to ``dispatch_fn``
|
||||||
|
(schema engine generic CRUD/action behaviour).
|
||||||
|
"""
|
||||||
|
|
||||||
|
registered = 0
|
||||||
|
for pack in packs.values():
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
for op in sorted(pack.operations, key=_route_priority):
|
||||||
|
key = (op.method, op.path)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
path = _fastapi_path(op.path)
|
||||||
|
full_path = f"/_os/{pack.name}{path}"
|
||||||
|
name = f"{_ROUTE_NAME_PREFIX}{pack.name}:{op.method}:{op.path}"
|
||||||
|
endpoint = _make_contract_endpoint(pack, op, handlers, dispatch_fn)
|
||||||
|
|
||||||
|
app.add_api_route(
|
||||||
|
full_path,
|
||||||
|
endpoint,
|
||||||
|
methods=[op.method],
|
||||||
|
name=name,
|
||||||
|
include_in_schema=True,
|
||||||
|
tags=[service_openapi_tag(pack.name)],
|
||||||
|
)
|
||||||
|
registered += 1
|
||||||
|
return registered
|
||||||
|
|
||||||
|
|
||||||
|
def _make_contract_endpoint(
|
||||||
|
pack: ServicePack,
|
||||||
|
op: OperationSpec,
|
||||||
|
handlers: HandlerRegistry,
|
||||||
|
dispatch_fn: Callable[[Request, ServicePack, OperationSpec], Awaitable[Response]],
|
||||||
|
) -> Handler:
|
||||||
|
async def endpoint(request: Request) -> Response:
|
||||||
|
request.state.os_contract_path = op.path
|
||||||
|
request.state.os_contract_op = op
|
||||||
|
handler = handlers.get(pack.name, op.path, op.method)
|
||||||
|
if handler is not None:
|
||||||
|
return await handler(request)
|
||||||
|
return await dispatch_fn(request, pack, op)
|
||||||
|
|
||||||
|
return endpoint
|
||||||
|
|
||||||
|
|
||||||
|
def register_specialized_orphan_routes(
|
||||||
|
app: FastAPI,
|
||||||
|
packs: dict[str, ServicePack],
|
||||||
|
handlers: HandlerRegistry,
|
||||||
|
) -> int:
|
||||||
|
"""Register specialized handler paths that are not declared in the contract pack.
|
||||||
|
|
||||||
|
Keeps trailing-slash version roots, PUT collection aliases, etc. that exist on
|
||||||
|
stateful routers but are missing from generated ``api.json`` packs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
declared: set[tuple[str, str, str]] = set()
|
||||||
|
declared_norm: set[tuple[str, str, str]] = set()
|
||||||
|
for pack in packs.values():
|
||||||
|
for op in pack.operations:
|
||||||
|
declared.add((pack.name, op.method.upper(), op.path))
|
||||||
|
declared_norm.add((pack.name, op.method.upper(), normalize_path_template(op.path)))
|
||||||
|
|
||||||
|
# Paths already mounted by the contract loop.
|
||||||
|
mounted: set[tuple[str, str, str]] = set()
|
||||||
|
for route in app.router.routes:
|
||||||
|
name = getattr(route, "name", None)
|
||||||
|
if not isinstance(name, str) or not name.startswith(_ROUTE_NAME_PREFIX):
|
||||||
|
continue
|
||||||
|
# os-contract:{service}:{METHOD}:{path}
|
||||||
|
rest = name[len(_ROUTE_NAME_PREFIX) :]
|
||||||
|
service, _, remainder = rest.partition(":")
|
||||||
|
method, _, path = remainder.partition(":")
|
||||||
|
mounted.add((service, method.upper(), path))
|
||||||
|
|
||||||
|
registered = 0
|
||||||
|
for service, path, verb in sorted(handlers.keys()):
|
||||||
|
verb_u = verb.upper()
|
||||||
|
if (service, verb_u, path) in declared or (service, verb_u, path) in mounted:
|
||||||
|
continue
|
||||||
|
if (service, verb_u, normalize_path_template(path)) in declared_norm:
|
||||||
|
continue
|
||||||
|
handler = handlers.get(service, path, verb_u)
|
||||||
|
if handler is None:
|
||||||
|
continue
|
||||||
|
full_path = f"/_os/{service}{_fastapi_path(path)}"
|
||||||
|
name = f"{_ROUTE_NAME_PREFIX}{service}:{verb_u}:{path}"
|
||||||
|
endpoint = _make_handler_only_endpoint(path, handler)
|
||||||
|
app.add_api_route(
|
||||||
|
full_path,
|
||||||
|
endpoint,
|
||||||
|
methods=[verb_u],
|
||||||
|
name=name,
|
||||||
|
include_in_schema=True,
|
||||||
|
tags=[service_openapi_tag(service)],
|
||||||
|
)
|
||||||
|
registered += 1
|
||||||
|
return registered
|
||||||
|
|
||||||
|
|
||||||
|
def _make_handler_only_endpoint(specialized_path: str, handler: Handler) -> Handler:
|
||||||
|
async def endpoint(request: Request) -> Response:
|
||||||
|
request.state.os_contract_path = specialized_path
|
||||||
|
return await handler(request)
|
||||||
|
|
||||||
|
return endpoint
|
||||||
|
|
||||||
|
|
||||||
|
def mount_contract_services(
|
||||||
|
app: FastAPI,
|
||||||
|
*,
|
||||||
|
packs: dict[str, ServicePack],
|
||||||
|
handlers: HandlerRegistry,
|
||||||
|
dispatch_fn: Callable[[Request, ServicePack, OperationSpec], Awaitable[Response]],
|
||||||
|
) -> int:
|
||||||
|
"""Clear previous contract routes and register from packs. Returns route count."""
|
||||||
|
|
||||||
|
# Preserve non-contract routes; insert contract routes before gen-* so static
|
||||||
|
# schema paths are not stolen by generic /{item_id}.
|
||||||
|
non_gen: list[Any] = []
|
||||||
|
gen: list[Any] = []
|
||||||
|
for route in app.router.routes:
|
||||||
|
name = getattr(route, "name", "") or ""
|
||||||
|
if isinstance(name, str) and name.startswith(_ROUTE_NAME_PREFIX):
|
||||||
|
continue
|
||||||
|
if isinstance(name, str) and name.startswith("schema-"):
|
||||||
|
# Legacy schema-* routes from older mounts — drop on rebuild.
|
||||||
|
continue
|
||||||
|
if isinstance(name, str) and name.startswith("gen-"):
|
||||||
|
gen.append(route)
|
||||||
|
else:
|
||||||
|
non_gen.append(route)
|
||||||
|
app.router.routes = non_gen
|
||||||
|
app.openapi_schema = None
|
||||||
|
count = register_openstack_contract_routes(app, packs, handlers, dispatch_fn=dispatch_fn)
|
||||||
|
count += register_specialized_orphan_routes(app, packs, handlers)
|
||||||
|
app.router.routes.extend(gen)
|
||||||
|
return count
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""HTTP routers for OpenStack services."""
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
"""Cinder Block Storage API v3 (lab subset)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated, Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from asyncpg import Connection
|
||||||
|
from fastapi import APIRouter, Depends, Request, Response
|
||||||
|
|
||||||
|
from app.openstack.auth import TokenContext
|
||||||
|
from app.openstack.deps import get_conn, require_project_token
|
||||||
|
from app.openstack.errors import OpenStackError
|
||||||
|
|
||||||
|
router = APIRouter(tags=["Cinder"])
|
||||||
|
|
||||||
|
|
||||||
|
def _volume(row: Any) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": str(row["id"]),
|
||||||
|
"name": row["name"],
|
||||||
|
"status": row["status"],
|
||||||
|
"size": row["size"],
|
||||||
|
"volume_type": row["volume_type"],
|
||||||
|
"bootable": "true" if row["bootable"] else "false",
|
||||||
|
"multiattach": False,
|
||||||
|
"encrypted": False,
|
||||||
|
"os-vol-tenant-attr:tenant_id": str(row["project_id"]),
|
||||||
|
"metadata": {},
|
||||||
|
"attachments": [],
|
||||||
|
"created_at": row["created_at"].strftime("%Y-%m-%dT%H:%M:%S.%f"),
|
||||||
|
"updated_at": row["updated_at"].strftime("%Y-%m-%dT%H:%M:%S.%f"),
|
||||||
|
"links": [
|
||||||
|
{"rel": "self", "href": f"/v3/{row['project_id']}/volumes/{row['id']}"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v3")
|
||||||
|
@router.get("/v3/")
|
||||||
|
async def cinder_versions(conn: Annotated[Connection, Depends(get_conn)]) -> dict[str, object]:
|
||||||
|
from app.openstack.db_docs import require_doc
|
||||||
|
|
||||||
|
return await require_doc(
|
||||||
|
conn, service="cinder", resource_type="discovery_version", name="default"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v3/{project_id}/volumes")
|
||||||
|
@router.get("/v3/{project_id}/volumes/detail")
|
||||||
|
@router.get("/v3/volumes")
|
||||||
|
@router.get("/v3/volumes/detail")
|
||||||
|
async def list_volumes(
|
||||||
|
request: Request,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||||
|
project_id: str | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
from app.openstack.paging import paginate_rows
|
||||||
|
|
||||||
|
_ = project_id # path project_id ignored; token scope wins
|
||||||
|
detail = request.url.path.rstrip("/").endswith("detail")
|
||||||
|
rows = list(
|
||||||
|
await conn.fetch(
|
||||||
|
"SELECT * FROM os_volumes WHERE project_id = $1 ORDER BY created_at, id",
|
||||||
|
ctx.project_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
page, links = paginate_rows(rows, request, id_attr=lambda r: str(r["id"]))
|
||||||
|
if detail:
|
||||||
|
body: dict[str, object] = {"volumes": [_volume(r) for r in page]}
|
||||||
|
else:
|
||||||
|
body = {
|
||||||
|
"volumes": [
|
||||||
|
{
|
||||||
|
"id": str(r["id"]),
|
||||||
|
"name": r["name"],
|
||||||
|
"links": [{"rel": "self", "href": f"/v3/{ctx.project_id}/volumes/{r['id']}"}],
|
||||||
|
}
|
||||||
|
for r in page
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if links:
|
||||||
|
body["volumes_links"] = links
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v3/{project_id}/volumes/{volume_id}")
|
||||||
|
@router.get("/v3/volumes/{volume_id}")
|
||||||
|
async def show_volume(
|
||||||
|
volume_id: str,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||||
|
project_id: str | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_ = project_id
|
||||||
|
# openstacksdk may probe GET /volumes/{name} before create
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""SELECT * FROM os_volumes
|
||||||
|
WHERE project_id = $2
|
||||||
|
AND (id::text = $1 OR name = $1)
|
||||||
|
ORDER BY CASE WHEN id::text = $1 THEN 0 ELSE 1 END
|
||||||
|
LIMIT 1""",
|
||||||
|
volume_id,
|
||||||
|
ctx.project_id,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise OpenStackError("ItemNotFound", "Volume could not be found", status_code=404)
|
||||||
|
return {"volume": _volume(row)}
|
||||||
|
|
||||||
|
|
||||||
|
async def _update_volume(
|
||||||
|
resource_id: str,
|
||||||
|
request: Request,
|
||||||
|
conn: Connection,
|
||||||
|
ctx: TokenContext,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
payload = (await request.json()).get("volume") or {}
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""UPDATE os_volumes
|
||||||
|
SET name = COALESCE($1, name), updated_at = now()
|
||||||
|
WHERE id = $2::uuid AND project_id = $3
|
||||||
|
RETURNING *""",
|
||||||
|
payload.get("name"),
|
||||||
|
resource_id,
|
||||||
|
ctx.project_id,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise OpenStackError("ItemNotFound", "Volume could not be found", status_code=404)
|
||||||
|
return {"volume": _volume(row)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/v3/{project_id}/volumes/{volume_id}")
|
||||||
|
@router.patch("/v3/{project_id}/volumes/{volume_id}")
|
||||||
|
@router.put("/v3/volumes/{volume_id}")
|
||||||
|
@router.patch("/v3/volumes/{volume_id}")
|
||||||
|
async def update_volume(
|
||||||
|
volume_id: str,
|
||||||
|
request: Request,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||||
|
project_id: str | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_ = project_id
|
||||||
|
return await _update_volume(volume_id, request, conn, ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/v3/{project_id}/volumes/{id}")
|
||||||
|
@router.patch("/v3/{project_id}/volumes/{id}")
|
||||||
|
@router.put("/v3/volumes/{id}")
|
||||||
|
@router.patch("/v3/volumes/{id}")
|
||||||
|
async def update_volume_by_id(
|
||||||
|
id: str,
|
||||||
|
request: Request,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||||
|
project_id: str | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_ = project_id
|
||||||
|
return await _update_volume(id, request, conn, ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/v3/{project_id}/volumes", status_code=202)
|
||||||
|
@router.post("/v3/volumes", status_code=202)
|
||||||
|
async def create_volume(
|
||||||
|
request: Request,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||||
|
project_id: str | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
from app.openstack.db_docs import fetch_doc
|
||||||
|
|
||||||
|
_ = project_id
|
||||||
|
payload = (await request.json()).get("volume") or {}
|
||||||
|
defaults = (
|
||||||
|
await fetch_doc(conn, service="cinder", resource_type="volume_defaults", name="default")
|
||||||
|
or {}
|
||||||
|
)
|
||||||
|
size = int(
|
||||||
|
payload.get("size") if payload.get("size") is not None else defaults.get("size") or 1
|
||||||
|
)
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""INSERT INTO os_volumes(id, project_id, name, status, size, volume_type, bootable)
|
||||||
|
VALUES($1, $2, $3, 'available', $4, $5, $6)
|
||||||
|
RETURNING *""",
|
||||||
|
uuid4(),
|
||||||
|
ctx.project_id,
|
||||||
|
payload.get("name") if payload.get("name") is not None else defaults.get("name") or "",
|
||||||
|
size,
|
||||||
|
payload.get("volume_type") or defaults.get("volume_type"),
|
||||||
|
bool(payload.get("bootable", False)),
|
||||||
|
)
|
||||||
|
return {"volume": _volume(row)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/v3/{project_id}/volumes/{volume_id}", status_code=202)
|
||||||
|
@router.delete("/v3/volumes/{volume_id}", status_code=202)
|
||||||
|
async def delete_volume(
|
||||||
|
volume_id: str,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||||
|
project_id: str | None = None,
|
||||||
|
) -> Response:
|
||||||
|
_ = project_id
|
||||||
|
result = await conn.execute(
|
||||||
|
"DELETE FROM os_volumes WHERE id = $1::uuid AND project_id = $2",
|
||||||
|
volume_id,
|
||||||
|
ctx.project_id,
|
||||||
|
)
|
||||||
|
if result.endswith("0"):
|
||||||
|
raise OpenStackError("ItemNotFound", "Volume could not be found", status_code=404)
|
||||||
|
return Response(status_code=202)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/v3/{project_id}/volumes/{volume_id}/action", status_code=202)
|
||||||
|
@router.post("/v3/volumes/{volume_id}/action", status_code=202)
|
||||||
|
async def volume_action(
|
||||||
|
volume_id: str,
|
||||||
|
request: Request,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||||
|
project_id: str | None = None,
|
||||||
|
) -> Response:
|
||||||
|
"""Lab subset of Cinder volume actions (os-extend, etc.)."""
|
||||||
|
_ = project_id
|
||||||
|
payload = await request.json()
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"SELECT * FROM os_volumes WHERE id = $1::uuid AND project_id = $2",
|
||||||
|
volume_id,
|
||||||
|
ctx.project_id,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise OpenStackError("ItemNotFound", "Volume could not be found", status_code=404)
|
||||||
|
|
||||||
|
if "os-extend" in payload:
|
||||||
|
new_size = int((payload.get("os-extend") or {}).get("new_size") or 0)
|
||||||
|
if new_size <= int(row["size"]):
|
||||||
|
raise OpenStackError(
|
||||||
|
"InvalidInput",
|
||||||
|
"new_size must be greater than current size",
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"""UPDATE os_volumes
|
||||||
|
SET size = $1, updated_at = now()
|
||||||
|
WHERE id = $2::uuid AND project_id = $3""",
|
||||||
|
new_size,
|
||||||
|
volume_id,
|
||||||
|
ctx.project_id,
|
||||||
|
)
|
||||||
|
return Response(status_code=202)
|
||||||
|
|
||||||
|
# Persist any other recognized lab action against the volume in PostgreSQL.
|
||||||
|
import json
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
action = next(iter(payload.keys()), "action") if isinstance(payload, dict) else "action"
|
||||||
|
status_map = {
|
||||||
|
"os-reserve": "in-use",
|
||||||
|
"os-unreserve": "available",
|
||||||
|
"os-attach": "in-use",
|
||||||
|
"os-detach": "available",
|
||||||
|
"os-begin_detaching": "in-use",
|
||||||
|
"os-roll_detaching": "in-use",
|
||||||
|
"os-force_detach": "available",
|
||||||
|
"os-reset_status": str(
|
||||||
|
((payload.get("os-reset_status") or {}) if isinstance(payload, dict) else {}).get(
|
||||||
|
"status"
|
||||||
|
)
|
||||||
|
or row["status"]
|
||||||
|
),
|
||||||
|
"os-set_bootable": row["status"],
|
||||||
|
"os-retype": row["status"],
|
||||||
|
"os-migrate_volume": row["status"],
|
||||||
|
"os-start": row["status"],
|
||||||
|
"os-stop": row["status"],
|
||||||
|
}
|
||||||
|
new_status = status_map.get(str(action), row["status"])
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE os_volumes SET status=$1, updated_at=now() WHERE id=$2::uuid AND project_id=$3",
|
||||||
|
new_status,
|
||||||
|
volume_id,
|
||||||
|
ctx.project_id,
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"""INSERT INTO os_api_objects(id, service, resource_type, project_id, name, status, data)
|
||||||
|
VALUES($1,'cinder','volume_action',$2,$3,'DONE',$4::jsonb)""",
|
||||||
|
uuid4(),
|
||||||
|
ctx.project_id,
|
||||||
|
f"{volume_id}:{action}",
|
||||||
|
json.dumps({"volume_id": volume_id, "action": action, "payload": payload}),
|
||||||
|
)
|
||||||
|
return Response(status_code=202)
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
"""Glance Image API v2 (lab subset)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated, Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from asyncpg import Connection
|
||||||
|
from fastapi import APIRouter, Depends, Request, Response
|
||||||
|
|
||||||
|
from app.openstack.auth import TokenContext
|
||||||
|
from app.openstack.deps import get_conn, require_project_token, require_token
|
||||||
|
from app.openstack.errors import OpenStackError
|
||||||
|
|
||||||
|
router = APIRouter(tags=["Glance"])
|
||||||
|
|
||||||
|
|
||||||
|
def _image(row: Any) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": str(row["id"]),
|
||||||
|
"name": row["name"],
|
||||||
|
"status": row["status"],
|
||||||
|
"visibility": row["visibility"],
|
||||||
|
"size": row["size"],
|
||||||
|
"disk_format": row["disk_format"],
|
||||||
|
"container_format": row["container_format"],
|
||||||
|
"min_disk": 0,
|
||||||
|
"min_ram": 0,
|
||||||
|
"protected": False,
|
||||||
|
"checksum": None,
|
||||||
|
"owner": str(row["owner_project_id"]) if row["owner_project_id"] else None,
|
||||||
|
"created_at": row["created_at"].strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
"updated_at": row["updated_at"].strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
"tags": [],
|
||||||
|
"file": f"/v2/images/{row['id']}/file",
|
||||||
|
"schema": "/v2/schemas/image",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v2")
|
||||||
|
@router.get("/v2/")
|
||||||
|
async def glance_versions(conn: Annotated[Connection, Depends(get_conn)]) -> dict[str, object]:
|
||||||
|
from app.openstack.db_docs import require_doc
|
||||||
|
|
||||||
|
return await require_doc(
|
||||||
|
conn, service="glance", resource_type="discovery_version", name="default"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v2/images")
|
||||||
|
async def list_images(
|
||||||
|
request: Request,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
ctx: Annotated[TokenContext, Depends(require_token)],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
from app.openstack.paging import paginate_rows
|
||||||
|
|
||||||
|
name = request.query_params.get("name")
|
||||||
|
if name:
|
||||||
|
rows = list(
|
||||||
|
await conn.fetch(
|
||||||
|
"""SELECT * FROM os_images
|
||||||
|
WHERE (visibility = 'public' OR owner_project_id = $1)
|
||||||
|
AND name = $2
|
||||||
|
ORDER BY created_at, id""",
|
||||||
|
ctx.project_id,
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
rows = list(
|
||||||
|
await conn.fetch(
|
||||||
|
"""SELECT * FROM os_images
|
||||||
|
WHERE visibility = 'public'
|
||||||
|
OR owner_project_id = $1
|
||||||
|
ORDER BY created_at, id""",
|
||||||
|
ctx.project_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
page, links = paginate_rows(rows, request, id_attr=lambda r: str(r["id"]))
|
||||||
|
body: dict[str, object] = {
|
||||||
|
"images": [_image(r) for r in page],
|
||||||
|
"first": "/v2/images",
|
||||||
|
"schema": "/v2/schemas/images",
|
||||||
|
}
|
||||||
|
if links:
|
||||||
|
body["images_links"] = links
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
async def _show_image(
|
||||||
|
resource_id: str,
|
||||||
|
conn: Connection,
|
||||||
|
ctx: TokenContext,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""SELECT * FROM os_images
|
||||||
|
WHERE (id::text = $1 OR name = $1)
|
||||||
|
AND (visibility = 'public' OR owner_project_id = $2)
|
||||||
|
ORDER BY CASE WHEN id::text = $1 THEN 0 ELSE 1 END
|
||||||
|
LIMIT 1""",
|
||||||
|
resource_id,
|
||||||
|
ctx.project_id,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise OpenStackError("ImageNotFound", "Image not found", status_code=404)
|
||||||
|
return _image(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v2/images/{image_id}")
|
||||||
|
async def show_image(
|
||||||
|
image_id: str,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
ctx: Annotated[TokenContext, Depends(require_token)],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return await _show_image(image_id, conn, ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v2/images/{id}")
|
||||||
|
async def show_image_by_id(
|
||||||
|
id: str,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
ctx: Annotated[TokenContext, Depends(require_token)],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return await _show_image(id, conn, ctx)
|
||||||
|
|
||||||
|
|
||||||
|
async def _update_image(
|
||||||
|
resource_id: str,
|
||||||
|
request: Request,
|
||||||
|
conn: Connection,
|
||||||
|
ctx: TokenContext,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
payload = await request.json()
|
||||||
|
body = payload.get("image") if isinstance(payload.get("image"), dict) else payload
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""UPDATE os_images
|
||||||
|
SET name = COALESCE($1, name), updated_at = now()
|
||||||
|
WHERE id = $2::uuid AND owner_project_id = $3
|
||||||
|
RETURNING *""",
|
||||||
|
body.get("name") if isinstance(body, dict) else None,
|
||||||
|
resource_id,
|
||||||
|
ctx.project_id,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise OpenStackError("ImageNotFound", "Image not found", status_code=404)
|
||||||
|
return _image(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/v2/images/{image_id}")
|
||||||
|
@router.patch("/v2/images/{image_id}")
|
||||||
|
async def update_image(
|
||||||
|
image_id: str,
|
||||||
|
request: Request,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return await _update_image(image_id, request, conn, ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/v2/images/{id}")
|
||||||
|
@router.patch("/v2/images/{id}")
|
||||||
|
async def update_image_by_id(
|
||||||
|
id: str,
|
||||||
|
request: Request,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return await _update_image(id, request, conn, ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/v2/images", status_code=201)
|
||||||
|
async def create_image(
|
||||||
|
request: Request,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
from app.openstack.db_docs import fetch_doc
|
||||||
|
|
||||||
|
payload = await request.json()
|
||||||
|
defaults = (
|
||||||
|
await fetch_doc(conn, service="glance", resource_type="image_defaults", name="default")
|
||||||
|
or {}
|
||||||
|
)
|
||||||
|
image_id = uuid4()
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""INSERT INTO os_images(id, name, status, visibility, size, disk_format,
|
||||||
|
container_format, owner_project_id)
|
||||||
|
VALUES($1, $2, 'queued', $3, 0, $4, $5, $6)
|
||||||
|
RETURNING *""",
|
||||||
|
image_id,
|
||||||
|
payload.get("name") or defaults.get("name") or "image",
|
||||||
|
payload.get("visibility") or defaults.get("visibility"),
|
||||||
|
payload.get("disk_format") or defaults.get("disk_format"),
|
||||||
|
payload.get("container_format") or defaults.get("container_format"),
|
||||||
|
ctx.project_id,
|
||||||
|
)
|
||||||
|
return _image(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v2/images/{image_id}/file")
|
||||||
|
async def download_image_file(
|
||||||
|
image_id: str,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||||
|
) -> Response:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""SELECT id, size FROM os_images
|
||||||
|
WHERE (id::text=$1 OR name=$1)
|
||||||
|
AND (owner_project_id=$2 OR visibility='public')
|
||||||
|
LIMIT 1""",
|
||||||
|
image_id,
|
||||||
|
ctx.project_id,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
# Materialize pack/schema image rows into os_images on first download.
|
||||||
|
api = await conn.fetchrow(
|
||||||
|
"""SELECT id, name, data FROM os_api_objects
|
||||||
|
WHERE service='glance' AND resource_type='image'
|
||||||
|
AND (id::text=$1 OR name=$1)
|
||||||
|
LIMIT 1""",
|
||||||
|
image_id,
|
||||||
|
)
|
||||||
|
if api is None:
|
||||||
|
raise OpenStackError("ImageNotFound", f"image {image_id} not found", status_code=404)
|
||||||
|
data = api["data"]
|
||||||
|
if isinstance(data, str):
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
data = _json.loads(data or "{}")
|
||||||
|
await conn.execute(
|
||||||
|
"""INSERT INTO os_images(id, name, status, visibility, size, disk_format,
|
||||||
|
container_format, owner_project_id)
|
||||||
|
VALUES($1::uuid,$2,'active',$3,$4,$5,$6,$7)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET updated_at=now()""",
|
||||||
|
api["id"],
|
||||||
|
api["name"] or (data or {}).get("name") or "image",
|
||||||
|
(data or {}).get("visibility") or "private",
|
||||||
|
int((data or {}).get("size") or 0),
|
||||||
|
(data or {}).get("disk_format") or "qcow2",
|
||||||
|
(data or {}).get("container_format") or "bare",
|
||||||
|
ctx.project_id,
|
||||||
|
)
|
||||||
|
size = int((data or {}).get("size") or 0)
|
||||||
|
else:
|
||||||
|
size = int(row["size"] or 0)
|
||||||
|
# Always return at least one byte so clients / coverage see a real payload.
|
||||||
|
content = b"\0" * min(size, 64) if size else b"probe-image"
|
||||||
|
return Response(
|
||||||
|
content=content,
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
headers={"Content-Length": str(len(content) if not size else size)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/v2/images/{image_id}/file", status_code=204)
|
||||||
|
async def upload_image_file(
|
||||||
|
image_id: str,
|
||||||
|
request: Request,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||||
|
) -> Response:
|
||||||
|
body = await request.body()
|
||||||
|
result = await conn.execute(
|
||||||
|
"""UPDATE os_images
|
||||||
|
SET status = 'active', size = $1, updated_at = now()
|
||||||
|
WHERE (id::text = $2 OR name = $2) AND owner_project_id = $3""",
|
||||||
|
len(body),
|
||||||
|
image_id,
|
||||||
|
ctx.project_id,
|
||||||
|
)
|
||||||
|
if result.endswith("0"):
|
||||||
|
raise OpenStackError("ImageNotFound", "Image not found", status_code=404)
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/v2/images/{image_id}", status_code=204)
|
||||||
|
async def delete_image(
|
||||||
|
image_id: str,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||||
|
) -> Response:
|
||||||
|
result = await conn.execute(
|
||||||
|
"DELETE FROM os_images WHERE id = $1::uuid AND owner_project_id = $2",
|
||||||
|
image_id,
|
||||||
|
ctx.project_id,
|
||||||
|
)
|
||||||
|
if result.endswith("0"):
|
||||||
|
raise OpenStackError("ImageNotFound", "Image not found", status_code=404)
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v2/info/stores")
|
||||||
|
async def glance_stores(
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
_ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
from app.openstack.db_docs import require_doc
|
||||||
|
|
||||||
|
return await require_doc(conn, service="glance", resource_type="info_stores", name="default")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v2/info/import")
|
||||||
|
async def glance_import_info(
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
_ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
from app.openstack.db_docs import require_doc
|
||||||
|
|
||||||
|
return await require_doc(conn, service="glance", resource_type="info_import", name="default")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v2/schemas/image")
|
||||||
|
async def glance_schema_image(
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
_ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
from app.openstack.db_docs import require_doc
|
||||||
|
|
||||||
|
return await require_doc(conn, service="glance", resource_type="schema", name="image")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v2/schemas/images")
|
||||||
|
async def glance_schema_images(
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
_ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
from app.openstack.db_docs import require_doc
|
||||||
|
|
||||||
|
return await require_doc(conn, service="glance", resource_type="schema", name="images")
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
"""Heat Orchestration API v1."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Annotated, Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from asyncpg import Connection
|
||||||
|
from fastapi import APIRouter, Depends, Request, Response
|
||||||
|
|
||||||
|
from app.openstack.auth import TokenContext
|
||||||
|
from app.openstack.deps import get_conn, require_project_token, require_token
|
||||||
|
from app.openstack.errors import OpenStackError
|
||||||
|
|
||||||
|
router = APIRouter(tags=["Heat"])
|
||||||
|
|
||||||
|
|
||||||
|
def _stack(row: Any) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": str(row["id"]),
|
||||||
|
"stack_name": row["stack_name"],
|
||||||
|
"stack_status": row["stack_status"],
|
||||||
|
"description": row["description"],
|
||||||
|
"creation_time": row["created_at"].strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
"updated_time": row["updated_at"].strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
"stack_owner": str(row["project_id"]),
|
||||||
|
"parent": None,
|
||||||
|
"stack_user_project_id": str(row["project_id"]),
|
||||||
|
"outputs": row["outputs"]
|
||||||
|
if not isinstance(row["outputs"], str)
|
||||||
|
else json.loads(row["outputs"]),
|
||||||
|
"parameters": row["parameters"]
|
||||||
|
if not isinstance(row["parameters"], str)
|
||||||
|
else json.loads(row["parameters"]),
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"rel": "self",
|
||||||
|
"href": f"/v1/{row['project_id']}/stacks/{row['stack_name']}/{row['id']}",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v1")
|
||||||
|
@router.get("/v1/")
|
||||||
|
async def heat_versions(conn: Annotated[Connection, Depends(get_conn)]) -> dict[str, object]:
|
||||||
|
from app.openstack.db_docs import require_doc
|
||||||
|
|
||||||
|
return await require_doc(
|
||||||
|
conn, service="heat", resource_type="discovery_version", name="default"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v1/{tenant_id}/stacks")
|
||||||
|
async def list_stacks(
|
||||||
|
tenant_id: str,
|
||||||
|
request: Request,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
from app.openstack.paging import paginate_rows
|
||||||
|
|
||||||
|
_ = tenant_id
|
||||||
|
rows = list(
|
||||||
|
await conn.fetch(
|
||||||
|
"SELECT * FROM os_stacks WHERE project_id = $1 ORDER BY created_at, id",
|
||||||
|
ctx.project_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
page, links = paginate_rows(rows, request, id_attr=lambda r: str(r["id"]))
|
||||||
|
body: dict[str, object] = {"stacks": [_stack(r) for r in page]}
|
||||||
|
if links:
|
||||||
|
body["stacks_links"] = links
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v1/{tenant_id}/stacks/detail")
|
||||||
|
async def list_stacks_detail(
|
||||||
|
tenant_id: str,
|
||||||
|
request: Request,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return await list_stacks(tenant_id, request, conn, ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/v1/{tenant_id}/stacks", status_code=201)
|
||||||
|
async def create_stack(
|
||||||
|
tenant_id: str,
|
||||||
|
request: Request,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
from app.openstack.db_docs import fetch_doc
|
||||||
|
|
||||||
|
_ = tenant_id
|
||||||
|
payload = await request.json()
|
||||||
|
stack = payload.get("stack") or payload
|
||||||
|
defaults = (
|
||||||
|
await fetch_doc(conn, service="heat", resource_type="stack_defaults", name="default") or {}
|
||||||
|
)
|
||||||
|
name = stack.get("stack_name") or stack.get("name") or f"stack-{uuid4().hex[:8]}"
|
||||||
|
template = (
|
||||||
|
stack.get("template")
|
||||||
|
if isinstance(stack.get("template"), dict)
|
||||||
|
else defaults.get("template")
|
||||||
|
)
|
||||||
|
parameters = (
|
||||||
|
stack.get("parameters")
|
||||||
|
if isinstance(stack.get("parameters"), dict)
|
||||||
|
else defaults.get("parameters")
|
||||||
|
)
|
||||||
|
if not isinstance(template, dict):
|
||||||
|
template = {}
|
||||||
|
if not isinstance(parameters, dict):
|
||||||
|
parameters = {}
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""INSERT INTO os_stacks(id, project_id, stack_name, stack_status, description, template, parameters, outputs)
|
||||||
|
VALUES($1,$2,$3,'CREATE_COMPLETE',$4,$5::jsonb,$6::jsonb,'[]'::jsonb) RETURNING *""",
|
||||||
|
uuid4(),
|
||||||
|
ctx.project_id,
|
||||||
|
name,
|
||||||
|
stack.get("description") or "",
|
||||||
|
json.dumps(template),
|
||||||
|
json.dumps(parameters),
|
||||||
|
)
|
||||||
|
return {"stack": _stack(row)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v1/{tenant_id}/stacks/{id}")
|
||||||
|
async def show_stack_by_id(
|
||||||
|
tenant_id: str,
|
||||||
|
id: str,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_ = tenant_id
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"SELECT * FROM os_stacks WHERE project_id=$1 AND (id::text=$2 OR stack_name=$2) ORDER BY created_at DESC LIMIT 1",
|
||||||
|
ctx.project_id,
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise OpenStackError("StackNotFound", "Stack not found", status_code=404)
|
||||||
|
return {"stack": _stack(row)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/v1/{tenant_id}/stacks/{id}")
|
||||||
|
@router.patch("/v1/{tenant_id}/stacks/{id}")
|
||||||
|
async def update_stack_by_id(
|
||||||
|
tenant_id: str,
|
||||||
|
id: str,
|
||||||
|
request: Request,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_ = tenant_id
|
||||||
|
payload = await request.json()
|
||||||
|
stack = payload.get("stack") or payload
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"SELECT * FROM os_stacks WHERE project_id=$1 AND (id::text=$2 OR stack_name=$2) ORDER BY created_at DESC LIMIT 1",
|
||||||
|
ctx.project_id,
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise OpenStackError("StackNotFound", "Stack not found", status_code=404)
|
||||||
|
desc = stack.get("description") if "description" in stack else row["description"]
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE os_stacks SET description=$1, updated_at=now(), stack_status='UPDATE_COMPLETE' WHERE id=$2",
|
||||||
|
desc,
|
||||||
|
row["id"],
|
||||||
|
)
|
||||||
|
row = await conn.fetchrow("SELECT * FROM os_stacks WHERE id=$1", row["id"])
|
||||||
|
return {"stack": _stack(row)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/v1/{tenant_id}/stacks/{id}", status_code=204)
|
||||||
|
async def delete_stack_by_id(
|
||||||
|
tenant_id: str,
|
||||||
|
id: str,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||||
|
) -> Response:
|
||||||
|
_ = tenant_id
|
||||||
|
result = await conn.execute(
|
||||||
|
"DELETE FROM os_stacks WHERE project_id=$1 AND (id::text=$2 OR stack_name=$2)",
|
||||||
|
ctx.project_id,
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
if result.endswith("0"):
|
||||||
|
raise OpenStackError("StackNotFound", "Stack not found", status_code=404)
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v1/{tenant_id}/stacks/{stack_name}/{stack_id}")
|
||||||
|
@router.get("/v1/{tenant_id}/stacks/{stack_name}")
|
||||||
|
async def show_stack(
|
||||||
|
tenant_id: str,
|
||||||
|
stack_name: str,
|
||||||
|
request: Request,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||||
|
stack_id: str | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_ = tenant_id
|
||||||
|
if stack_name == "detail" and stack_id is None:
|
||||||
|
from app.openstack.paging import paginate_rows
|
||||||
|
|
||||||
|
rows = list(
|
||||||
|
await conn.fetch(
|
||||||
|
"SELECT * FROM os_stacks WHERE project_id = $1 ORDER BY created_at, id",
|
||||||
|
ctx.project_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
page, links = paginate_rows(rows, request, id_attr=lambda r: str(r["id"]))
|
||||||
|
body: dict[str, object] = {"stacks": [_stack(r) for r in page]}
|
||||||
|
if links:
|
||||||
|
body["stacks_links"] = links
|
||||||
|
return body
|
||||||
|
if stack_id:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"SELECT * FROM os_stacks WHERE project_id=$1 AND id::text=$2",
|
||||||
|
ctx.project_id,
|
||||||
|
stack_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"SELECT * FROM os_stacks WHERE project_id=$1 AND stack_name=$2 ORDER BY created_at DESC LIMIT 1",
|
||||||
|
ctx.project_id,
|
||||||
|
stack_name,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise OpenStackError("StackNotFound", "Stack not found", status_code=404)
|
||||||
|
return {"stack": _stack(row)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/v1/{tenant_id}/stacks/{stack_name}/{stack_id}", status_code=204)
|
||||||
|
async def delete_stack(
|
||||||
|
tenant_id: str,
|
||||||
|
stack_name: str,
|
||||||
|
stack_id: str,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
ctx: Annotated[TokenContext, Depends(require_project_token)],
|
||||||
|
) -> Response:
|
||||||
|
_ = tenant_id, stack_name
|
||||||
|
result = await conn.execute(
|
||||||
|
"DELETE FROM os_stacks WHERE project_id=$1 AND id::text=$2",
|
||||||
|
ctx.project_id,
|
||||||
|
stack_id,
|
||||||
|
)
|
||||||
|
if result.endswith("0"):
|
||||||
|
raise OpenStackError("StackNotFound", "Stack not found", status_code=404)
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v1/{tenant_id}/resource_types")
|
||||||
|
async def resource_types(
|
||||||
|
tenant_id: str,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
_ctx: Annotated[TokenContext, Depends(require_token)],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_ = tenant_id
|
||||||
|
from app.openstack.db_docs import require_doc
|
||||||
|
|
||||||
|
return await require_doc(
|
||||||
|
conn, service="heat", resource_type="resource_type_list", name="default"
|
||||||
|
)
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
"""Ironic Bare Metal API v1."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Annotated, Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from asyncpg import Connection
|
||||||
|
from fastapi import APIRouter, Depends, Request, Response
|
||||||
|
|
||||||
|
from app.openstack.auth import TokenContext
|
||||||
|
from app.openstack.deps import get_conn, require_token
|
||||||
|
from app.openstack.errors import OpenStackError
|
||||||
|
|
||||||
|
router = APIRouter(tags=["Ironic"])
|
||||||
|
|
||||||
|
|
||||||
|
def _node(row: Any) -> dict[str, Any]:
|
||||||
|
props = row["properties"]
|
||||||
|
if isinstance(props, str):
|
||||||
|
props = json.loads(props)
|
||||||
|
return {
|
||||||
|
"id": str(row["id"]),
|
||||||
|
"uuid": str(row["id"]),
|
||||||
|
"name": row["name"],
|
||||||
|
"driver": row["driver"],
|
||||||
|
"provision_state": row["provision_state"],
|
||||||
|
"power_state": row["power_state"],
|
||||||
|
"resource_class": row["resource_class"],
|
||||||
|
"properties": props or {},
|
||||||
|
"driver_info": row["driver_info"]
|
||||||
|
if not isinstance(row["driver_info"], str)
|
||||||
|
else json.loads(row["driver_info"]),
|
||||||
|
"ports": row["ports"] if not isinstance(row["ports"], str) else json.loads(row["ports"]),
|
||||||
|
"maintenance": False,
|
||||||
|
"links": [{"rel": "self", "href": f"/v1/nodes/{row['id']}"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v1")
|
||||||
|
@router.get("/v1/")
|
||||||
|
async def ironic_versions(conn: Annotated[Connection, Depends(get_conn)]) -> dict[str, object]:
|
||||||
|
from app.openstack.db_docs import require_doc
|
||||||
|
|
||||||
|
return await require_doc(
|
||||||
|
conn, service="ironic", resource_type="discovery_version", name="default"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v1/nodes")
|
||||||
|
async def list_nodes(
|
||||||
|
request: Request,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
_ctx: Annotated[TokenContext, Depends(require_token)],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
from app.openstack.paging import paginate_rows
|
||||||
|
|
||||||
|
rows = list(await conn.fetch("SELECT * FROM os_nodes ORDER BY name, id"))
|
||||||
|
page, links = paginate_rows(rows, request, id_attr=lambda r: str(r["id"]))
|
||||||
|
body: dict[str, object] = {"nodes": [_node(r) for r in page]}
|
||||||
|
if links:
|
||||||
|
body["nodes_links"] = links
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/v1/nodes", status_code=201)
|
||||||
|
async def create_node(
|
||||||
|
request: Request,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
_ctx: Annotated[TokenContext, Depends(require_token)],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
from app.openstack.db_docs import fetch_doc
|
||||||
|
|
||||||
|
payload = await request.json()
|
||||||
|
defaults = (
|
||||||
|
await fetch_doc(conn, service="ironic", resource_type="node_defaults", name="default") or {}
|
||||||
|
)
|
||||||
|
props = (
|
||||||
|
payload.get("properties")
|
||||||
|
if isinstance(payload.get("properties"), dict)
|
||||||
|
else defaults.get("properties")
|
||||||
|
)
|
||||||
|
if not isinstance(props, dict):
|
||||||
|
props = {}
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""INSERT INTO os_nodes(id, name, driver, provision_state, power_state, resource_class, properties, driver_info, ports)
|
||||||
|
VALUES($1,$2,$3,'available','power off',$4,$5::jsonb,$6::jsonb,'[]'::jsonb) RETURNING *""",
|
||||||
|
uuid4(),
|
||||||
|
payload.get("name") or f"node-{uuid4().hex[:8]}",
|
||||||
|
payload.get("driver") or defaults.get("driver"),
|
||||||
|
payload.get("resource_class") or defaults.get("resource_class"),
|
||||||
|
json.dumps(props),
|
||||||
|
json.dumps(payload.get("driver_info") or {}),
|
||||||
|
)
|
||||||
|
return _node(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v1/nodes/{node_id}")
|
||||||
|
async def show_node(
|
||||||
|
node_id: str,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
_ctx: Annotated[TokenContext, Depends(require_token)],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
row = await conn.fetchrow("SELECT * FROM os_nodes WHERE id::text=$1 OR name=$1", node_id)
|
||||||
|
if row is None:
|
||||||
|
raise OpenStackError("NotFound", "Node not found", status_code=404)
|
||||||
|
return _node(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def _update_node(
|
||||||
|
resource_id: str,
|
||||||
|
request: Request,
|
||||||
|
conn: Connection,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
payload = await request.json()
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""UPDATE os_nodes
|
||||||
|
SET name = COALESCE($1, name), updated_at = now()
|
||||||
|
WHERE id::text = $2 OR name = $2
|
||||||
|
RETURNING *""",
|
||||||
|
payload.get("name"),
|
||||||
|
resource_id,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise OpenStackError("NotFound", "Node not found", status_code=404)
|
||||||
|
return _node(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/v1/nodes/{node_id}")
|
||||||
|
@router.patch("/v1/nodes/{node_id}")
|
||||||
|
async def update_node(
|
||||||
|
node_id: str,
|
||||||
|
request: Request,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
_ctx: Annotated[TokenContext, Depends(require_token)],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return await _update_node(node_id, request, conn)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/v1/nodes/{id}")
|
||||||
|
@router.patch("/v1/nodes/{id}")
|
||||||
|
async def update_node_by_id(
|
||||||
|
id: str,
|
||||||
|
request: Request,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
_ctx: Annotated[TokenContext, Depends(require_token)],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return await _update_node(id, request, conn)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/v1/nodes/{node_id}", status_code=204)
|
||||||
|
async def delete_node(
|
||||||
|
node_id: str,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
_ctx: Annotated[TokenContext, Depends(require_token)],
|
||||||
|
) -> Response:
|
||||||
|
result = await conn.execute("DELETE FROM os_nodes WHERE id::text=$1 OR name=$1", node_id)
|
||||||
|
if result.endswith("0"):
|
||||||
|
raise OpenStackError("NotFound", "Node not found", status_code=404)
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/v1/nodes/{node_id}/states/provision")
|
||||||
|
@router.put("/v1/nodes/{node_id}/states/power")
|
||||||
|
async def node_state(
|
||||||
|
node_id: str,
|
||||||
|
request: Request,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
_ctx: Annotated[TokenContext, Depends(require_token)],
|
||||||
|
) -> Response:
|
||||||
|
from app.openstack.db_docs import fetch_doc
|
||||||
|
|
||||||
|
payload = await request.json()
|
||||||
|
target = payload.get("target") or payload.get("state")
|
||||||
|
defaults = (
|
||||||
|
await fetch_doc(conn, service="ironic", resource_type="node_defaults", name="default") or {}
|
||||||
|
)
|
||||||
|
row = await conn.fetchrow("SELECT id FROM os_nodes WHERE id::text=$1 OR name=$1", node_id)
|
||||||
|
if row is None:
|
||||||
|
raise OpenStackError("NotFound", "Node not found", status_code=404)
|
||||||
|
if "power" in request.url.path:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE os_nodes SET power_state=$1, updated_at=now() WHERE id=$2",
|
||||||
|
target or defaults.get("power_state"),
|
||||||
|
row["id"],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE os_nodes SET provision_state=$1, updated_at=now() WHERE id=$2",
|
||||||
|
target or defaults.get("provision_state"),
|
||||||
|
row["id"],
|
||||||
|
)
|
||||||
|
return Response(status_code=202)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v1/drivers")
|
||||||
|
async def list_drivers(
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
_ctx: Annotated[TokenContext, Depends(require_token)],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""SELECT id, name, data FROM os_api_objects
|
||||||
|
WHERE service='ironic' AND resource_type='driver'
|
||||||
|
ORDER BY created_at NULLS LAST, name"""
|
||||||
|
)
|
||||||
|
drivers: list[dict[str, object]] = []
|
||||||
|
for row in rows:
|
||||||
|
data = row["data"] if isinstance(row["data"], dict) else _json.loads(row["data"] or "{}")
|
||||||
|
drivers.append(
|
||||||
|
{
|
||||||
|
"name": row["name"] or data.get("name"),
|
||||||
|
"hosts": list(data.get("hosts") or []),
|
||||||
|
"type": data.get("type"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"drivers": drivers}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v1/nodes/{node_ident}/states")
|
||||||
|
async def node_states(
|
||||||
|
node_ident: str,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
_ctx: Annotated[TokenContext, Depends(require_token)],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"SELECT power_state, provision_state FROM os_nodes WHERE id::text=$1 OR name=$1",
|
||||||
|
node_ident,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise OpenStackError("NotFound", f"node {node_ident} not found", status_code=404)
|
||||||
|
return {
|
||||||
|
"power": row["power_state"],
|
||||||
|
"provision": row["provision_state"],
|
||||||
|
"raid": None,
|
||||||
|
"console": False,
|
||||||
|
"boot_mode": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v1/nodes/{node_ident}/vendor_passthru")
|
||||||
|
async def node_vendor_passthru(
|
||||||
|
node_ident: str,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
_ctx: Annotated[TokenContext, Depends(require_token)],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
exists = await conn.fetchval(
|
||||||
|
"SELECT 1 FROM os_nodes WHERE id::text=$1 OR name=$1",
|
||||||
|
node_ident,
|
||||||
|
)
|
||||||
|
if not exists:
|
||||||
|
raise OpenStackError("NotFound", f"node {node_ident} not found", status_code=404)
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""SELECT data FROM os_api_objects
|
||||||
|
WHERE service='ironic' AND resource_type='vendor_passthru'
|
||||||
|
AND (name=$1 OR data->>'node_id'=$1 OR data->>'node_uuid'=$1)
|
||||||
|
ORDER BY updated_at DESC LIMIT 1""",
|
||||||
|
node_ident,
|
||||||
|
)
|
||||||
|
if row is not None:
|
||||||
|
data = row["data"]
|
||||||
|
if isinstance(data, str):
|
||||||
|
data = json.loads(data)
|
||||||
|
methods = (data or {}).get("methods") or (data or {}).get("vendor_passthru") or data
|
||||||
|
if isinstance(methods, dict) and methods:
|
||||||
|
return {"vendor_passthru": methods}
|
||||||
|
return {"vendor_passthru": {"heartbeat": {"http_methods": ["POST"], "async": True}}}
|
||||||
|
# Persist empty methods doc so subsequent GETs are DB-backed.
|
||||||
|
await conn.execute(
|
||||||
|
"""INSERT INTO os_api_objects(id, service, resource_type, project_id, name, status, data)
|
||||||
|
VALUES($1,'ironic','vendor_passthru',NULL,$2,'ACTIVE',$3::jsonb)
|
||||||
|
ON CONFLICT (id) DO NOTHING""",
|
||||||
|
uuid4(),
|
||||||
|
node_ident,
|
||||||
|
json.dumps({"node_id": node_ident, "methods": {}}),
|
||||||
|
)
|
||||||
|
return {"vendor_passthru": {"heartbeat": {"http_methods": ["POST"], "async": True}}}
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
"""Keystone Identity API v3 (lab subset)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
from asyncpg import Connection, Pool
|
||||||
|
from fastapi import APIRouter, Depends, Request, Response
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from app.openstack.auth import extract_token, issue_token, validate_token
|
||||||
|
from app.openstack.catalog import build_catalog_from_db
|
||||||
|
from app.openstack.db_docs import require_doc
|
||||||
|
from app.openstack.deps import (
|
||||||
|
get_conn,
|
||||||
|
get_pool,
|
||||||
|
request_public_host,
|
||||||
|
request_scheme,
|
||||||
|
require_token,
|
||||||
|
)
|
||||||
|
from app.openstack.errors import OpenStackError
|
||||||
|
from app.openstack.auth import TokenContext
|
||||||
|
|
||||||
|
router = APIRouter(tags=["Keystone"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v3")
|
||||||
|
@router.get("/v3/")
|
||||||
|
async def v3_root(
|
||||||
|
request: Request,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
doc = await require_doc(
|
||||||
|
conn, service="keystone", resource_type="discovery_version", name="default"
|
||||||
|
)
|
||||||
|
# Prefer nested version object when present; otherwise wrap values[0].
|
||||||
|
if "version" in doc:
|
||||||
|
return doc
|
||||||
|
values = (doc.get("versions") or {}).get("values") or []
|
||||||
|
if values:
|
||||||
|
host = request_public_host(request)
|
||||||
|
scheme = request_scheme(request)
|
||||||
|
version = dict(values[0])
|
||||||
|
version["links"] = [{"rel": "self", "href": f"{scheme}://{host}:5000/v3/"}]
|
||||||
|
return {"version": version}
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/v3/auth/tokens")
|
||||||
|
async def create_token(
|
||||||
|
request: Request,
|
||||||
|
pool: Annotated[Pool, Depends(get_pool)],
|
||||||
|
) -> Response:
|
||||||
|
payload = await request.json()
|
||||||
|
auth = payload.get("auth") or {}
|
||||||
|
identity = auth.get("identity") or {}
|
||||||
|
methods = identity.get("methods") or []
|
||||||
|
if "password" not in methods:
|
||||||
|
raise OpenStackError(
|
||||||
|
"BadRequest", "Only password authentication is supported", status_code=400
|
||||||
|
)
|
||||||
|
password_block = (identity.get("password") or {}).get("user") or {}
|
||||||
|
user_name = password_block.get("name")
|
||||||
|
password = password_block.get("password")
|
||||||
|
domain_name = ((password_block.get("domain") or {}).get("name")) or "Default"
|
||||||
|
if not user_name or password is None:
|
||||||
|
raise OpenStackError("BadRequest", "user name and password are required", status_code=400)
|
||||||
|
|
||||||
|
scope = auth.get("scope") or {}
|
||||||
|
project_name = None
|
||||||
|
if "project" in scope:
|
||||||
|
project_name = (scope["project"] or {}).get("name")
|
||||||
|
if not project_name and (scope["project"] or {}).get("id"):
|
||||||
|
# resolve by id later via SQL
|
||||||
|
project_name = None
|
||||||
|
project_id = scope["project"]["id"]
|
||||||
|
else:
|
||||||
|
project_id = None
|
||||||
|
else:
|
||||||
|
project_id = None
|
||||||
|
|
||||||
|
host = request_public_host(request)
|
||||||
|
scheme = request_scheme(request)
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
if project_id and not project_name:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"SELECT name FROM os_projects WHERE id = $1::uuid", project_id
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise OpenStackError("Unauthorized", "Project not found", status_code=401)
|
||||||
|
project_name = str(row["name"])
|
||||||
|
|
||||||
|
token_id, body = await issue_token(
|
||||||
|
conn,
|
||||||
|
user_name=str(user_name),
|
||||||
|
password=str(password),
|
||||||
|
project_name=str(project_name) if project_name else None,
|
||||||
|
domain_name=str(domain_name),
|
||||||
|
host=host,
|
||||||
|
scheme=scheme,
|
||||||
|
)
|
||||||
|
return JSONResponse(status_code=201, content=body, headers={"X-Subject-Token": token_id})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v3/auth/tokens")
|
||||||
|
async def show_token(
|
||||||
|
request: Request,
|
||||||
|
pool: Annotated[Pool, Depends(get_pool)],
|
||||||
|
) -> Response:
|
||||||
|
subject = request.headers.get("X-Subject-Token") or extract_token(
|
||||||
|
{k: v for k, v in request.headers.items()}
|
||||||
|
)
|
||||||
|
if not subject:
|
||||||
|
raise OpenStackError("Unauthorized", "X-Subject-Token required", status_code=401)
|
||||||
|
# Also require caller token in normal Keystone, but lab accepts subject alone or auth token.
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
ctx = await validate_token(conn, subject)
|
||||||
|
domain = await conn.fetchrow(
|
||||||
|
"""SELECT d.id, d.name FROM os_domains d
|
||||||
|
JOIN os_users u ON u.domain_id = d.id WHERE u.id = $1""",
|
||||||
|
ctx.user_id,
|
||||||
|
)
|
||||||
|
host = request_public_host(request)
|
||||||
|
scheme = request_scheme(request)
|
||||||
|
body: dict[str, Any] = {
|
||||||
|
"token": {
|
||||||
|
"methods": ["password"],
|
||||||
|
"expires_at": ctx.expires_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||||
|
"user": {
|
||||||
|
"id": str(ctx.user_id),
|
||||||
|
"name": ctx.user_name,
|
||||||
|
"domain": {
|
||||||
|
"id": str(domain["id"]) if domain else "",
|
||||||
|
"name": str(domain["name"]) if domain else "Default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"roles": [{"id": r, "name": r} for r in ctx.roles],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ctx.project_id is not None:
|
||||||
|
body["token"]["project"] = {
|
||||||
|
"id": str(ctx.project_id),
|
||||||
|
"name": ctx.project_name,
|
||||||
|
"domain": {
|
||||||
|
"id": str(domain["id"]) if domain else "",
|
||||||
|
"name": str(domain["name"]) if domain else "Default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body["token"]["catalog"] = await build_catalog_from_db(conn, host, scheme=scheme)
|
||||||
|
return JSONResponse(content=body, headers={"X-Subject-Token": subject})
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/v3/auth/tokens", status_code=204)
|
||||||
|
async def revoke_token(
|
||||||
|
request: Request,
|
||||||
|
pool: Annotated[Pool, Depends(get_pool)],
|
||||||
|
) -> Response:
|
||||||
|
subject = request.headers.get("X-Subject-Token")
|
||||||
|
if not subject:
|
||||||
|
raise OpenStackError("BadRequest", "X-Subject-Token required", status_code=400)
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.execute("UPDATE os_tokens SET revoked = true WHERE id = $1", subject)
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v3/auth/catalog")
|
||||||
|
async def auth_catalog(
|
||||||
|
request: Request,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
ctx: Annotated[TokenContext, Depends(require_token)],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
if ctx.project_id is None:
|
||||||
|
raise OpenStackError("Forbidden", "Project-scoped token required", status_code=403)
|
||||||
|
catalog = await build_catalog_from_db(
|
||||||
|
conn,
|
||||||
|
request_public_host(request),
|
||||||
|
scheme=request_scheme(request),
|
||||||
|
)
|
||||||
|
return {"catalog": catalog}
|
||||||
|
|
||||||
|
|
||||||
|
def _project_body(row: Any) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"id": str(row["id"]),
|
||||||
|
"name": row["name"],
|
||||||
|
"description": row["description"],
|
||||||
|
"enabled": row["enabled"],
|
||||||
|
"domain_id": str(row["domain_id"]),
|
||||||
|
"is_domain": False,
|
||||||
|
"parent_id": str(row["domain_id"]),
|
||||||
|
"links": {"self": f"/v3/projects/{row['id']}"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _user_body(row: Any) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"id": str(row["id"]),
|
||||||
|
"name": row["name"],
|
||||||
|
"enabled": row["enabled"],
|
||||||
|
"domain_id": str(row["domain_id"]),
|
||||||
|
"links": {"self": f"/v3/users/{row['id']}"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v3/projects")
|
||||||
|
async def list_projects(
|
||||||
|
request: Request,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
ctx: Annotated[TokenContext, Depends(require_token)],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
from app.openstack.paging import paginate_rows
|
||||||
|
|
||||||
|
if ctx.is_admin:
|
||||||
|
rows = list(
|
||||||
|
await conn.fetch(
|
||||||
|
"SELECT id, name, description, enabled, domain_id FROM os_projects ORDER BY name, id"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
rows = list(
|
||||||
|
await conn.fetch(
|
||||||
|
"""SELECT p.id, p.name, p.description, p.enabled, p.domain_id
|
||||||
|
FROM os_projects p
|
||||||
|
JOIN os_role_assignments a ON a.project_id = p.id
|
||||||
|
WHERE a.user_id = $1
|
||||||
|
ORDER BY p.name, p.id""",
|
||||||
|
ctx.user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
page, links = paginate_rows(rows, request, id_attr=lambda r: str(r["id"]))
|
||||||
|
body: dict[str, object] = {
|
||||||
|
"projects": [_project_body(r) for r in page],
|
||||||
|
"links": {"next": None, "previous": None, "self": "/v3/projects"},
|
||||||
|
}
|
||||||
|
if links:
|
||||||
|
body["projects_links"] = links
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v3/projects/{project_id}")
|
||||||
|
async def show_project(
|
||||||
|
project_id: str,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
ctx: Annotated[TokenContext, Depends(require_token)],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"SELECT id, name, description, enabled, domain_id FROM os_projects WHERE id = $1::uuid",
|
||||||
|
project_id,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise OpenStackError("NotFound", f"Could not find project: {project_id}", status_code=404)
|
||||||
|
if not ctx.is_admin:
|
||||||
|
allowed = await conn.fetchval(
|
||||||
|
"""SELECT 1 FROM os_role_assignments
|
||||||
|
WHERE user_id = $1 AND project_id = $2::uuid LIMIT 1""",
|
||||||
|
ctx.user_id,
|
||||||
|
project_id,
|
||||||
|
)
|
||||||
|
if not allowed and str(ctx.project_id or "") != project_id:
|
||||||
|
raise OpenStackError(
|
||||||
|
"Forbidden", "Not authorized to access this project", status_code=403
|
||||||
|
)
|
||||||
|
return {"project": _project_body(row)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v3/users")
|
||||||
|
async def list_users(
|
||||||
|
request: Request,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
ctx: Annotated[TokenContext, Depends(require_token)],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
from app.openstack.paging import paginate_rows
|
||||||
|
|
||||||
|
if not ctx.is_admin:
|
||||||
|
rows = list(
|
||||||
|
await conn.fetch(
|
||||||
|
"SELECT id, name, enabled, domain_id FROM os_users WHERE id = $1",
|
||||||
|
ctx.user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
rows = list(
|
||||||
|
await conn.fetch("SELECT id, name, enabled, domain_id FROM os_users ORDER BY name, id")
|
||||||
|
)
|
||||||
|
page, links = paginate_rows(rows, request, id_attr=lambda r: str(r["id"]))
|
||||||
|
body: dict[str, object] = {"users": [_user_body(r) for r in page]}
|
||||||
|
if links:
|
||||||
|
body["users_links"] = links
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v3/users/{user_id}")
|
||||||
|
async def show_user(
|
||||||
|
user_id: str,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
ctx: Annotated[TokenContext, Depends(require_token)],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
if not ctx.is_admin and str(ctx.user_id) != user_id:
|
||||||
|
raise OpenStackError("Forbidden", "Not authorized to access this user", status_code=403)
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"SELECT id, name, enabled, domain_id FROM os_users WHERE id = $1::uuid",
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise OpenStackError("NotFound", f"Could not find user: {user_id}", status_code=404)
|
||||||
|
return {"user": _user_body(row)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v3/domains")
|
||||||
|
async def list_domains(
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
_ctx: Annotated[TokenContext, Depends(require_token)],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"SELECT id, name, description, enabled FROM os_domains ORDER BY name, id"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"domains": [
|
||||||
|
{
|
||||||
|
"id": str(r["id"]),
|
||||||
|
"name": r["name"],
|
||||||
|
"description": r["description"],
|
||||||
|
"enabled": r["enabled"],
|
||||||
|
"links": {"self": f"/v3/domains/{r['id']}"},
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v3/domains/{domain_id}")
|
||||||
|
async def show_domain(
|
||||||
|
domain_id: str,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
_ctx: Annotated[TokenContext, Depends(require_token)],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""SELECT id, name, description, enabled FROM os_domains
|
||||||
|
WHERE id::text = $1 OR name = $1""",
|
||||||
|
domain_id,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise OpenStackError("NotFound", f"Could not find domain: {domain_id}", status_code=404)
|
||||||
|
return {
|
||||||
|
"domain": {
|
||||||
|
"id": str(row["id"]),
|
||||||
|
"name": row["name"],
|
||||||
|
"description": row["description"],
|
||||||
|
"enabled": row["enabled"],
|
||||||
|
"links": {"self": f"/v3/domains/{row['id']}"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v3/roles")
|
||||||
|
async def list_roles(
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
_ctx: Annotated[TokenContext, Depends(require_token)],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
rows = await conn.fetch("SELECT id, name FROM os_roles ORDER BY name")
|
||||||
|
return {"roles": [{"id": str(r["id"]), "name": r["name"]} for r in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/v3/roles/{role_id}")
|
||||||
|
async def show_role(
|
||||||
|
role_id: str,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
_ctx: Annotated[TokenContext, Depends(require_token)],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"SELECT id, name FROM os_roles WHERE id::text = $1 OR name = $1",
|
||||||
|
role_id,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise OpenStackError("NotFound", f"Could not find role: {role_id}", status_code=404)
|
||||||
|
return {"role": {"id": str(row["id"]), "name": row["name"]}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/v3/projects", status_code=201)
|
||||||
|
async def create_project(
|
||||||
|
request: Request,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
ctx: Annotated[TokenContext, Depends(require_token)],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
payload = (await request.json()).get("project") or {}
|
||||||
|
domain_id = payload.get("domain_id") or await conn.fetchval(
|
||||||
|
"SELECT id FROM os_domains ORDER BY name LIMIT 1"
|
||||||
|
)
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""INSERT INTO os_projects(id, domain_id, name, description, enabled)
|
||||||
|
VALUES($1,$2,$3,$4,$5) RETURNING id, name, description, enabled, domain_id""",
|
||||||
|
uuid4(),
|
||||||
|
domain_id,
|
||||||
|
str(payload.get("name") or f"project-{uuid4().hex[:8]}"),
|
||||||
|
payload.get("description") or "",
|
||||||
|
bool(payload.get("enabled", True)),
|
||||||
|
)
|
||||||
|
_ = ctx
|
||||||
|
return {"project": _project_body(row)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/v3/users", status_code=201)
|
||||||
|
async def create_user(
|
||||||
|
request: Request,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
ctx: Annotated[TokenContext, Depends(require_token)],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from app.security.auth import hash_secret
|
||||||
|
|
||||||
|
payload = (await request.json()).get("user") or {}
|
||||||
|
domain_id = payload.get("domain_id") or await conn.fetchval(
|
||||||
|
"SELECT id FROM os_domains ORDER BY name LIMIT 1"
|
||||||
|
)
|
||||||
|
password = str(payload.get("password") or "secret")
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""INSERT INTO os_users(id, domain_id, name, password_hash, enabled)
|
||||||
|
VALUES($1,$2,$3,$4,$5) RETURNING id, name, enabled, domain_id""",
|
||||||
|
uuid4(),
|
||||||
|
domain_id,
|
||||||
|
str(payload.get("name") or f"user-{uuid4().hex[:8]}"),
|
||||||
|
hash_secret(password, salt=b"openstack-sim-v1"),
|
||||||
|
bool(payload.get("enabled", True)),
|
||||||
|
)
|
||||||
|
_ = ctx
|
||||||
|
return {"user": _user_body(row)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/v3/domains", status_code=201)
|
||||||
|
async def create_domain(
|
||||||
|
request: Request,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
ctx: Annotated[TokenContext, Depends(require_token)],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
payload = (await request.json()).get("domain") or {}
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""INSERT INTO os_domains(id, name, description, enabled)
|
||||||
|
VALUES($1,$2,$3,$4) RETURNING id, name, description, enabled""",
|
||||||
|
uuid4(),
|
||||||
|
str(payload.get("name") or f"domain-{uuid4().hex[:8]}"),
|
||||||
|
payload.get("description") or "",
|
||||||
|
bool(payload.get("enabled", True)),
|
||||||
|
)
|
||||||
|
_ = ctx
|
||||||
|
return {
|
||||||
|
"domain": {
|
||||||
|
"id": str(row["id"]),
|
||||||
|
"name": row["name"],
|
||||||
|
"description": row["description"],
|
||||||
|
"enabled": row["enabled"],
|
||||||
|
"links": {"self": f"/v3/domains/{row['id']}"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/v3/roles", status_code=201)
|
||||||
|
async def create_role(
|
||||||
|
request: Request,
|
||||||
|
conn: Annotated[Connection, Depends(get_conn)],
|
||||||
|
ctx: Annotated[TokenContext, Depends(require_token)],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
payload = (await request.json()).get("role") or {}
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""INSERT INTO os_roles(id, name) VALUES($1,$2) RETURNING id, name""",
|
||||||
|
uuid4(),
|
||||||
|
str(payload.get("name") or f"role-{uuid4().hex[:8]}"),
|
||||||
|
)
|
||||||
|
_ = ctx
|
||||||
|
return {"role": {"id": str(row["id"]), "name": row["name"]}}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user