diff --git a/app/ovirt/common.py b/app/ovirt/common.py new file mode 100644 index 0000000..2e327ba --- /dev/null +++ b/app/ovirt/common.py @@ -0,0 +1,43 @@ +"""Shared oVirt Engine helpers (existence checks, native NotFound faults).""" + +from __future__ import annotations + +from typing import Any + +from asyncpg import Connection + +from app.ovirt.errors import OVirtError + + +def no_such(kind: str, entity_id: str) -> OVirtError: + """Engine-shaped 404 with the bad id in the detail (never an HTML page).""" + + return OVirtError( + "NotFound", + f"No such {kind} ('{entity_id}')", + status_code=404, + ) + + +async def require_host(conn: Connection, host_id: str) -> Any: + row = await conn.fetchrow("SELECT * FROM ov_hosts WHERE id=$1::uuid", host_id) + if row is None: + raise no_such("host", host_id) + return row + + +async def require_datacenter(conn: Connection, datacenter_id: str) -> Any: + row = await conn.fetchrow( + "SELECT * FROM ov_datacenters WHERE id=$1::uuid", + datacenter_id, + ) + if row is None: + raise no_such("datacenter", datacenter_id) + return row + + +async def require_cluster(conn: Connection, cluster_id: str) -> Any: + row = await conn.fetchrow("SELECT * FROM ov_clusters WHERE id=$1::uuid", cluster_id) + if row is None: + raise no_such("cluster", cluster_id) + return row diff --git a/app/ovirt/repr.py b/app/ovirt/repr.py index 874e6e2..eae0d74 100644 --- a/app/ovirt/repr.py +++ b/app/ovirt/repr.py @@ -244,11 +244,17 @@ def user_entity(row: Any) -> dict[str, Any]: } -def generic_entity(collection: str, element: str, row: Any) -> dict[str, Any]: +def generic_entity( + collection: str, + element: str, + row: Any, + *, + entity_id: str | None = None, +) -> dict[str, Any]: if row is None: - from app.ovirt.errors import OVirtError + from app.ovirt.common import no_such - raise OVirtError("NotFound", f"{element} not found", status_code=404) + raise no_such(element, entity_id or "unknown") oid = str(row["id"]) data = _data(row) entity = { diff --git a/app/ovirt/routes/engine.py b/app/ovirt/routes/engine.py index 8ad3769..b812e6a 100644 --- a/app/ovirt/routes/engine.py +++ b/app/ovirt/routes/engine.py @@ -11,6 +11,7 @@ from uuid import uuid4 from asyncpg import Connection from fastapi import APIRouter, Request, Response +from app.ovirt.common import no_such, require_cluster, require_datacenter, require_host from app.ovirt.deps import get_db, require_auth from app.ovirt.errors import OVirtError from app.ovirt.jobs import create_job, respond_action @@ -170,7 +171,7 @@ async def handle_engine_request(request: Request) -> Response: raise except (TypeError, AttributeError) as exc: # Missing rows often crash in *_entity builders (row is None). - raise OVirtError("NotFound", "resource not found", status_code=404) from exc + raise no_such("resource", rel or "/") from exc except Exception as exc: # Integrity / UUID parse failures from coverage mutations → client error. name = type(exc).__name__ @@ -1045,13 +1046,12 @@ async def _handle_hosts( row = await conn.fetchrow("SELECT * FROM ov_hosts WHERE id=$1", hid) return respond(request, element="host", data=host_entity(row), status_code=201) host_id = parts[1] - row = await conn.fetchrow("SELECT * FROM ov_hosts WHERE id=$1::uuid", host_id) if len(parts) == 2: if method == "GET": - if row is None: - raise OVirtError("NotFound", "host not found", status_code=404) + row = await require_host(conn, host_id) return respond(request, element="host", data=host_entity(row)) if method == "PUT": + await require_host(conn, host_id) body = unwrap_entity(payload, "host") await conn.execute( "UPDATE ov_hosts SET name=COALESCE($2,name), address=COALESCE($3,address), updated_at=now() WHERE id=$1::uuid", @@ -1059,11 +1059,15 @@ async def _handle_hosts( body.get("name"), body.get("address"), ) - row = await conn.fetchrow("SELECT * FROM ov_hosts WHERE id=$1::uuid", host_id) + row = await require_host(conn, host_id) return respond(request, element="host", data=host_entity(row)) if method == "DELETE": + await require_host(conn, host_id) await conn.execute("DELETE FROM ov_hosts WHERE id=$1::uuid", host_id) return Response(status_code=200) + row = await conn.fetchrow("SELECT * FROM ov_hosts WHERE id=$1::uuid", host_id) + if row is None: + raise no_such("host", host_id) if len(parts) == 3 and method == "POST": action = parts[2].lower() status_map = await option_json(conn, OPT_HOST_ACTION_STATUS_MAP) @@ -1129,11 +1133,10 @@ async def _handle_datacenters( return respond(request, element="data_center", data=datacenter_entity(row), status_code=201) dc_id = parts[1] if len(parts) == 2 and method == "GET": - row = await conn.fetchrow("SELECT * FROM ov_datacenters WHERE id=$1::uuid", dc_id) - if row is None: - raise OVirtError("NotFound", "datacenter not found", status_code=404) + row = await require_datacenter(conn, dc_id) return respond(request, element="data_center", data=datacenter_entity(row)) if len(parts) == 2 and method == "PUT": + await require_datacenter(conn, dc_id) body = unwrap_entity(payload, "data_center") await conn.execute( "UPDATE ov_datacenters SET name=COALESCE($2,name), description=COALESCE($3,description), updated_at=now() WHERE id=$1::uuid", @@ -1141,9 +1144,10 @@ async def _handle_datacenters( body.get("name"), body.get("description"), ) - row = await conn.fetchrow("SELECT * FROM ov_datacenters WHERE id=$1::uuid", dc_id) + row = await require_datacenter(conn, dc_id) return respond(request, element="data_center", data=datacenter_entity(row)) if len(parts) == 2 and method == "DELETE": + await require_datacenter(conn, dc_id) await conn.execute("DELETE FROM ov_datacenters WHERE id=$1::uuid", dc_id) return Response(status_code=200) if len(parts) == 3 and method == "POST" and parts[2] == "cleanfinishedtasks": @@ -1225,9 +1229,7 @@ async def _dc_clusters( request: Request, conn: Connection, method: str, parts: list[str], payload: dict[str, Any] ) -> Response: dc_id = parts[1] - dc = await conn.fetchrow("SELECT id FROM ov_datacenters WHERE id=$1::uuid", dc_id) - if dc is None: - raise OVirtError("NotFound", "datacenter not found", status_code=404) + await require_datacenter(conn, dc_id) if len(parts) == 3 and method == "GET": rows = await conn.fetch( "SELECT * FROM ov_clusters WHERE datacenter_id=$1::uuid ORDER BY name", dc_id @@ -1263,7 +1265,7 @@ async def _dc_clusters( dc_id, ) if row is None: - raise OVirtError("NotFound", "cluster not found", status_code=404) + raise no_such("cluster", parts[3]) return respond(request, element="cluster", data=cluster_entity(row)) if len(parts) == 4 and method == "DELETE": await conn.execute( @@ -1279,9 +1281,7 @@ async def _dc_networks( request: Request, conn: Connection, method: str, parts: list[str], payload: dict[str, Any] ) -> Response: dc_id = parts[1] - dc = await conn.fetchrow("SELECT id FROM ov_datacenters WHERE id=$1::uuid", dc_id) - if dc is None: - raise OVirtError("NotFound", "datacenter not found", status_code=404) + await require_datacenter(conn, dc_id) if len(parts) == 3 and method == "GET": rows = await conn.fetch( "SELECT * FROM ov_networks WHERE datacenter_id=$1::uuid ORDER BY name", dc_id @@ -1427,11 +1427,10 @@ async def _handle_clusters( return respond(request, element="cluster", data=cluster_entity(row), status_code=201) cluster_id = parts[1] if len(parts) == 2 and method == "GET": - row = await conn.fetchrow("SELECT * FROM ov_clusters WHERE id=$1::uuid", cluster_id) - if row is None: - raise OVirtError("NotFound", "cluster not found", status_code=404) + row = await require_cluster(conn, cluster_id) return respond(request, element="cluster", data=cluster_entity(row)) if len(parts) == 2 and method == "PUT": + await require_cluster(conn, cluster_id) body = unwrap_entity(payload, "cluster") await conn.execute( "UPDATE ov_clusters SET name=COALESCE($2,name), description=COALESCE($3,description) WHERE id=$1::uuid", @@ -1439,9 +1438,10 @@ async def _handle_clusters( body.get("name"), body.get("description"), ) - row = await conn.fetchrow("SELECT * FROM ov_clusters WHERE id=$1::uuid", cluster_id) + row = await require_cluster(conn, cluster_id) return respond(request, element="cluster", data=cluster_entity(row)) if len(parts) == 2 and method == "DELETE": + await require_cluster(conn, cluster_id) await conn.execute("DELETE FROM ov_clusters WHERE id=$1::uuid", cluster_id) return Response(status_code=200) if len(parts) >= 3 and parts[2] == "affinitygroups": @@ -1542,9 +1542,7 @@ async def _cluster_networks( del payload cluster_id = parts[1] - cluster = await conn.fetchrow("SELECT * FROM ov_clusters WHERE id=$1::uuid", cluster_id) - if cluster is None: - raise OVirtError("NotFound", "cluster not found", status_code=404) + cluster = await require_cluster(conn, cluster_id) dc_id = cluster["datacenter_id"] if len(parts) == 3 and method == "GET": rows = await conn.fetch( diff --git a/app/ovirt/schema_engine.py b/app/ovirt/schema_engine.py index 7079bf2..511b731 100644 --- a/app/ovirt/schema_engine.py +++ b/app/ovirt/schema_engine.py @@ -123,11 +123,19 @@ async def handle_generic( ) if method == "GET": if row is None: - raise OVirtError("NotFound", f"{element} not found", status_code=404) - return respond(request, element=element, data=generic_entity(collection, element, row)) + from app.ovirt.common import no_such + + raise no_such(element, oid) + return respond( + request, + element=element, + data=generic_entity(collection, element, row, entity_id=oid), + ) if method == "PUT": if row is None: - raise OVirtError("NotFound", f"{element} not found", status_code=404) + from app.ovirt.common import no_such + + raise no_such(element, oid) body = unwrap_entity(payload, element) data = dict(json.loads(row["data"]) if isinstance(row["data"], str) else row["data"] or {}) data.update(body) @@ -330,8 +338,10 @@ async def handle_subcollection( parent_id, ) if row is None: - raise OVirtError("NotFound", f"{element} not found", status_code=404) - data = generic_entity(sub, element, row) + from app.ovirt.common import no_such + + raise no_such(element, oid) + data = generic_entity(sub, element, row, entity_id=oid) data["href"] = f"/ovirt-engine/api/{parent_collection}/{parent_id}/{sub}/{oid}" return respond(request, element=element, data=data) if method == "DELETE": diff --git a/app/web/index.html b/app/web/index.html index 8bcf1f2..58e22bf 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -4740,12 +4740,7 @@ if (!res.ok) throw new Error("session expired"); persistAuth(); } catch { - state.ticket = null; - state.csrf = null; - state.username = null; - state.project = null; - localStorage.removeItem(LS_AUTH); - setAuth(false); + clearSession({ closePanel: false }); } } @@ -5994,6 +5989,23 @@ } } + function clearSession(options = {}) { + const { toastMessage = null, toastKind = "info", closePanel = true } = options; + state.ticket = null; + state.csrf = null; + state.username = null; + state.project = null; + document.cookie = "JSESSIONID=; Max-Age=0; path=/; SameSite=Strict"; + document.cookie = "ovirt_token=; Max-Age=0; path=/; SameSite=Strict"; + try { localStorage.removeItem(LS_AUTH); } catch {} + try { sessionStorage.removeItem(LS_AUTH); } catch {} + setAuth(false); + persistAuth(); + if (closePanel) closeAuthPanel(); + resetClusterStats("—"); + if (toastMessage) toast(toastMessage, toastKind); + } + function logout() { const token = state.ticket; if (token) { @@ -6002,15 +6014,15 @@ headers: { Authorization: `Bearer ${token}` }, }).catch(() => {}); } - state.ticket = null; - state.csrf = null; - state.username = null; - state.project = null; - setAuth(false); - persistAuth(); - closeAuthPanel(); - resetClusterStats("—"); - toast("Signed out", "info"); + clearSession({ toastMessage: "Signed out", toastKind: "info" }); + } + + function expireSession() { + clearSession({ + toastMessage: "Session expired — sign in again", + toastKind: "warn", + closePanel: false, + }); } function updateStatusBadge(status) { @@ -6087,6 +6099,9 @@ const text = await res.text(); let parsed; try { parsed = JSON.parse(text); } catch { parsed = text; } + if (res.status === 401 && (state.ticket || state.username)) { + expireSession(); + } return { status: res.status, body: parsed, durationMs: Math.round(performance.now() - started) }; } diff --git a/docs/configuration.md b/docs/configuration.md index 7bb1b30..be3f165 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -73,8 +73,11 @@ See [Kubernetes / Helm](kubernetes.md) and | `secrets.ticketSigningKey` | Must be rotated for shared clusters | | `service.port` | ClusterIP port (default `8080`) | -`gateway.*` / `ingress.*` in `values.yaml` are reserved; the chart currently -exposes FastAPI directly (no Compose-style nginx gateway). +`gateway.*` is unused (no Compose-style nginx gateway in the chart). +`ingress.*` is optional — see +[`values-ingress-example.yaml`](../helm/ovirt-api-simulator/values-ingress-example.yaml) +and [Troubleshooting](troubleshooting.md) for annotations that preserve Engine +fault bodies. ## Contract packs diff --git a/docs/kubernetes.md b/docs/kubernetes.md index 64a7ffd..778de55 100644 --- a/docs/kubernetes.md +++ b/docs/kubernetes.md @@ -19,9 +19,12 @@ Published image (when pushed): | **seed** Job (optional) | `minimal` or `demo` lab data | > The Compose stack publishes Engine HTTPS + UI via nginx `api-gateway` -> ([ports.md](ports.md)). The Helm chart **does not yet** ship that gateway: -> `gateway.*` / `ingress.*` keys in `values.yaml` are reserved and unused. -> Access the simulator Service on `:8080` (port-forward or your own Ingress). +> ([ports.md](ports.md)). The Helm chart serves the simulator Service on `:8080`. +> Optional Ingress: enable with +> [`values-ingress-example.yaml`](../helm/ovirt-api-simulator/values-ingress-example.yaml) +> (`proxy-intercept-errors: "false"`, `custom-http-errors: "502,503"` so Engine +> fault bodies are not rewritten to branded HTML). See +> [Troubleshooting](troubleshooting.md). ## Prerequisites diff --git a/docs/prompts/fix-api-errors-auth-ui-other-sims.md b/docs/prompts/fix-api-errors-auth-ui-other-sims.md new file mode 100644 index 0000000..520b03e --- /dev/null +++ b/docs/prompts/fix-api-errors-auth-ui-other-sims.md @@ -0,0 +1,92 @@ +# Prompt: fix API error bodies + Web UI session on 401 (other simulators) + +Copy everything below the line into a chat opened on **ovirt_api_simulator**, +**vmware_api_simulator**, or **openstack_api_simulator**. Adapt product names +(API path prefixes, cookie/CSRF names, Helm chart paths) to that repo. + +--- + +## Task + +Fix the same class of production issues already fixed in +`proxmox_api_simulator` (reference implementation). Do **not** claim the +simulator is “incomplete” or “not supported”; keep durable, realistic API +behavior. + +### Symptoms seen behind Ingress + +1. Client calls a path with a **wrong / missing resource id** (e.g. bad node / + host / datacenter name). Instead of the simulator’s JSON/XML error, the + browser or curl receives a **branded HTML 404** (“page not found”) from the + cluster Ingress / custom error pages. +2. Client sends a mutation (**POST/PUT/DELETE**) that the Ingress rejects or + rewrites → **nginx HTML 405** instead of the simulator’s auth/validation + error. +3. In the **Web UI**, after the API returns **401** (expired session), the + response panel says unauthorized but the **header still shows the signed-in + user** (does not switch to Guest / signed-out). + +Root cause for (1)/(2) is usually **ingress-nginx** +`custom-http-errors` / `proxy-intercept-errors` replacing upstream bodies. +Root cause for (3) is UI code that renders 401 text but does not clear +ticket/token/cookie/localStorage and call the same path as logout. + +### What to implement in THIS repo + +#### 1. Clearer “resource not found” API errors + +- Find the shared helper that validates that a node/host/cluster/datacenter + exists (equivalent of Proxmox `require_node`). +- On miss, return the **native API error shape** for this product with a + message that includes the bad id, e.g. Proxmox style: + `No such node ('pve01')` plus a field-level `errors` map when the product + uses one. +- Update unit tests that matched the old message string. +- Ensure the app always returns `Content-Type` appropriate for the API + (JSON/XML) and never an HTML error page from the app itself. + +#### 2. Web UI: treat HTTP 401 as session expiry + +- When any in-app API helper gets **401** and a session is currently stored: + - clear ticket/token/csrf/username (whatever this UI uses); + - clear auth cookies; + - clear persisted auth in localStorage/sessionStorage; + - update the header auth pill / badge to the signed-out / Guest state; + - show a short toast like “Session expired — sign in again”; + - still show the 401 explanation in the response panel. +- Refactor logout to share one `clearSession(...)` helper so logout and + expiry stay in sync. Do not leave stale “signed in as …” UI after 401. + +#### 3. Helm / Ingress example + docs + +- In the chart’s ingress example values (and kubernetes troubleshooting docs): + - `nginx.ingress.kubernetes.io/proxy-intercept-errors: "false"` + - `nginx.ingress.kubernetes.io/custom-http-errors: "502,503"` + (do **not** list 404/405 so API bodies are preserved) +- Document: if clients still see branded HTML 404/405, the cluster controller + is rewriting responses — fix Ingress annotations, not the simulator handlers. +- Document a correct authenticated mutation curl for this product (cookie / + token + CSRF if required + the body encoding this API expects — often + form-urlencoded, not bare JSON). + +### Out of scope + +- Do not add “not implemented in the simulator” user-facing messages. +- Do not change unrelated product semantics. +- Do not force-push or commit unless the user asks. + +### Done when + +- Missing resource → product-shaped API error with the id in the message + (verified by unit test and/or curl against the app, not only via Ingress). +- UI 401 → Guest / signed-out header + cleared storage/cookies. +- Ingress example + troubleshooting docs warn about HTML error-page rewrite + and show the correct curl pattern. +- Lint/tests for touched areas pass. + +### Reference (proxmox_api_simulator) + +- `app/handlers/common.py` — `require_node` / `node_metadata` message +- `app/web/index.html` — `clearSession` / `expireSession` / `showResponse` 401 +- `helm/proxmox-api-simulator/values-ingress-example.yaml` — annotations +- `docs/troubleshooting.md` — Ingress HTML 404/405 + curl notes diff --git a/docs/ru/configuration.md b/docs/ru/configuration.md index ea38332..71b46fd 100644 --- a/docs/ru/configuration.md +++ b/docs/ru/configuration.md @@ -73,8 +73,11 @@ Docker Compose подставляет многие из них для серви | `secrets.ticketSigningKey` | Нужно ротировать на общих кластерах | | `service.port` | Порт ClusterIP (по умолчанию `8080`) | -`gateway.*` / `ingress.*` в `values.yaml` зарезервированы; чарт сейчас отдаёт -FastAPI напрямую (без nginx gateway как в Compose). +`gateway.*` не используется (нет nginx gateway как в Compose). +`ingress.*` опционален — см. +[`values-ingress-example.yaml`](../../helm/ovirt-api-simulator/values-ingress-example.yaml) +и [Устранение неполадок](troubleshooting.md) для annotations, сохраняющих +тела fault Engine. ## Контрактные packs diff --git a/docs/ru/kubernetes.md b/docs/ru/kubernetes.md index cd6b9f3..76e224a 100644 --- a/docs/ru/kubernetes.md +++ b/docs/ru/kubernetes.md @@ -19,10 +19,12 @@ | **seed** Job (опционально) | Лабораторные данные `minimal` или `demo` | > Compose публикует Engine HTTPS + UI через nginx `api-gateway` -> ([ports.md](ports.md)). Helm-чарт **пока не** включает этот gateway: -> ключи `gateway.*` / `ingress.*` в `values.yaml` зарезервированы и не -> используются. Доступ — к Service симулятора на `:8080` (port-forward или -> свой Ingress). +> ([ports.md](ports.md)). Helm-чарт отдаёт Service симулятора на `:8080`. +> Опциональный Ingress: +> [`values-ingress-example.yaml`](../../helm/ovirt-api-simulator/values-ingress-example.yaml) +> (`proxy-intercept-errors: "false"`, `custom-http-errors: "502,503"`, чтобы +> fault Engine не подменялся брендированным HTML). См. +> [Устранение неполадок](troubleshooting.md). ## Требования diff --git a/docs/ru/troubleshooting.md b/docs/ru/troubleshooting.md index 38ed894..e2f402e 100644 --- a/docs/ru/troubleshooting.md +++ b/docs/ru/troubleshooting.md @@ -38,6 +38,49 @@ make seed-demo Проверьте, что `OVIRT_SERIES` соответствует ожидаемому pack ([api-versions.md](api-versions.md)). +## HTTP 401 / Web UI всё ещё показывает пользователя + +Токен Engine SSO истёк или отозван (например после `demo/unload` / reseed). +В Web UI HTTP **401** очищает локальную сессию и показывает **Guest** в +шапке; войдите снова из Environment (`admin@internal` / `secret`). + +## Ingress отдаёт брендированный HTML 404 / nginx 405 вместо fault Engine + +Симулятор отвечает на ошибки API **fault** XML/JSON (`reason` / `detail`). +Если видите HTML «page not found» или plain nginx **405**, **Ingress / +reverse proxy** подменил тело ответа (часто через `custom-http-errors` у +ingress-nginx). + +Исправление на Ingress для этого host (см. +`helm/ovirt-api-simulator/values-ingress-example.yaml`): + +```yaml +annotations: + nginx.ingress.kubernetes.io/proxy-intercept-errors: "false" + nginx.ingress.kubernetes.io/custom-http-errors: "502,503" +``` + +Проверьте с `Accept: application/json`. Отсутствующий host должен выглядеть так: + +```json +{"fault": {"reason": "NotFound", "detail": "No such host ('…')"}} +``` + +### Корректная authenticated mutation (стиль Engine) + +Bearer (или Basic) и JSON-тело (не form-urlencoded): + +```bash +# после POST /ovirt-engine/sso/oauth/token → access_token +TOKEN=… +curl -sk -X POST "https://HOST/ovirt-engine/api/vms" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Accept: application/json" \ + -H "Content-Type: application/json" \ + -H "Version: 4" \ + -d '{"vm":{"name":"lab-vm","cluster":{"name":"Default"}}}' +``` + ## Падения клиентских suites Убедитесь, что стек поднят и засеян, затем сначала smoke: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 0824b0e..0630556 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -36,6 +36,49 @@ make seed-demo Set `Version: 4` (or `3`) or use `/ovirt-engine/api/v4/...`. Confirm `OVIRT_SERIES` matches the pack you expect ([api-versions.md](api-versions.md)). +## HTTP 401 / Web UI still shows signed-in user + +Engine SSO token expired or was revoked (e.g. after `demo/unload` / reseed). +In the Web UI, HTTP **401** clears the local session and shows **Guest** in the +header; sign in again from Environment (`admin@internal` / `secret`). + +## Ingress returns branded HTML 404 / nginx 405 instead of Engine fault + +The simulator answers API errors as Engine **fault** XML/JSON +(`reason` / `detail`). If you see a site HTML “page not found” or plain nginx +**405** page, the **Ingress / reverse proxy** replaced the upstream body (often +via `custom-http-errors` on the ingress-nginx controller). + +Fix on the Ingress for this host (see +`helm/ovirt-api-simulator/values-ingress-example.yaml`): + +```yaml +annotations: + nginx.ingress.kubernetes.io/proxy-intercept-errors: "false" + nginx.ingress.kubernetes.io/custom-http-errors: "502,503" +``` + +Then re-check with `Accept: application/json`. A missing host should look like: + +```json +{"fault": {"reason": "NotFound", "detail": "No such host ('…')"}} +``` + +### Correct authenticated mutation (Engine-style) + +Use Bearer (or Basic) auth and JSON body (not form-urlencoded): + +```bash +# after POST /ovirt-engine/sso/oauth/token → access_token +TOKEN=… +curl -sk -X POST "https://HOST/ovirt-engine/api/vms" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Accept: application/json" \ + -H "Content-Type: application/json" \ + -H "Version: 4" \ + -d '{"vm":{"name":"lab-vm","cluster":{"name":"Default"}}}' +``` + ## Client suite failures Ensure the stack is up and seeded, then run smoke first: diff --git a/helm/ovirt-api-simulator/README.md b/helm/ovirt-api-simulator/README.md index 5e64fd5..b793bbf 100644 --- a/helm/ovirt-api-simulator/README.md +++ b/helm/ovirt-api-simulator/README.md @@ -15,8 +15,9 @@ helm upgrade --install ovirt-sim . \ --set secrets.ticketSigningKey="$(openssl rand -hex 32)" ``` -Access via port-forward to Service `:8080` (Engine API, SSO, Web UI, `/docs`). -The Compose nginx gateway is **not** part of this chart yet. +Access via port-forward to Service `:8080` (Engine API, SSO, Web UI, `/docs`), +or enable Ingress with +[`values-ingress-example.yaml`](values-ingress-example.yaml). Full guide: [docs/kubernetes.md](../../docs/kubernetes.md). Values: [`values.yaml`](values.yaml). diff --git a/helm/ovirt-api-simulator/README.ru.md b/helm/ovirt-api-simulator/README.ru.md index ede7d93..5776350 100644 --- a/helm/ovirt-api-simulator/README.ru.md +++ b/helm/ovirt-api-simulator/README.ru.md @@ -15,8 +15,9 @@ helm upgrade --install ovirt-sim . \ --set secrets.ticketSigningKey="$(openssl rand -hex 32)" ``` -Доступ через port-forward к Service `:8080` (Engine API, SSO, Web UI, `/docs`). -Nginx gateway из Compose в этот чарт **пока не** входит. +Доступ через port-forward к Service `:8080` (Engine API, SSO, Web UI, `/docs`) +или через Ingress с +[`values-ingress-example.yaml`](values-ingress-example.yaml). Полное руководство: [docs/ru/kubernetes.md](../../docs/ru/kubernetes.md). Values: [`values.yaml`](values.yaml). diff --git a/helm/ovirt-api-simulator/templates/ingress.yaml b/helm/ovirt-api-simulator/templates/ingress.yaml new file mode 100644 index 0000000..54310eb --- /dev/null +++ b/helm/ovirt-api-simulator/templates/ingress.yaml @@ -0,0 +1,43 @@ +{{- if .Values.ingress.enabled -}} +{{- $fullName := include "ovirt-api-simulator.fullname" . -}} +{{- $svcPort := .Values.service.port -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ $fullName }} + labels: + app: {{ include "ovirt-api-simulator.name" . }} + annotations: + {{- with .Values.ingress.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ $fullName }} + port: + number: {{ $svcPort }} + {{- end }} + {{- end }} +{{- end }} diff --git a/helm/ovirt-api-simulator/values-ingress-example.yaml b/helm/ovirt-api-simulator/values-ingress-example.yaml new file mode 100644 index 0000000..09c1f50 --- /dev/null +++ b/helm/ovirt-api-simulator/values-ingress-example.yaml @@ -0,0 +1,49 @@ +# Example: public Ingress + Hub image. +# +# WARNING: Laboratory / demo template. Always override weak secrets: +# --set secrets.ticketSigningKey="$(openssl rand -hex 32)" +# --set postgresql.auth.password="$(openssl rand -hex 16)" +# Do not expose a public Ingress with the placeholder values below. +# +# helm upgrade --install ovirt-sim ./helm/ovirt-api-simulator \ +# -n ovirt-sim --create-namespace \ +# -f helm/ovirt-api-simulator/values-ingress-example.yaml \ +# --set ingress.hosts[0].host=ovirt-sim.example.com \ +# --set secrets.ticketSigningKey="$(openssl rand -hex 32)" \ +# --set postgresql.auth.password="$(openssl rand -hex 16)" + +image: + repository: inecs/ovirt-api-simulator + tag: "0.1.0" + pullPolicy: IfNotPresent + +secrets: + ticketSigningKey: "replace-me" + +postgresql: + enabled: true + auth: + username: ovirt + password: "replace-me-db-password" + database: ovirt_simulator + +seed: + enabled: true + profile: minimal + +ingress: + enabled: true + className: nginx + # Keep Engine XML/JSON fault bodies (404/405/401). Cluster-wide + # custom-http-errors HTML pages must not rewrite simulator error responses. + annotations: + nginx.ingress.kubernetes.io/proxy-intercept-errors: "false" + # If the controller still injects branded HTML for 404/405, narrow or clear + # custom-http-errors on this Ingress (overrides controller defaults): + nginx.ingress.kubernetes.io/custom-http-errors: "502,503" + hosts: + - host: ovirt-sim.example.com + paths: + - path: / + pathType: Prefix + tls: [] diff --git a/helm/ovirt-api-simulator/values.yaml b/helm/ovirt-api-simulator/values.yaml index f3e7395..924255c 100644 --- a/helm/ovirt-api-simulator/values.yaml +++ b/helm/ovirt-api-simulator/values.yaml @@ -20,11 +20,16 @@ gateway: ingress: enabled: false className: nginx + # Prefer values-ingress-example.yaml for public Ingress. Keep API fault bodies: + # nginx.ingress.kubernetes.io/proxy-intercept-errors: "false" + # nginx.ingress.kubernetes.io/custom-http-errors: "502,503" # not 404/405 + annotations: {} hosts: - host: ovirt-engine.local paths: - path: / pathType: Prefix + tls: [] config: ovirtSeries: "4.5" diff --git a/tests/unit/test_ovirt_common.py b/tests/unit/test_ovirt_common.py new file mode 100644 index 0000000..1eff7c4 --- /dev/null +++ b/tests/unit/test_ovirt_common.py @@ -0,0 +1,35 @@ +"""Unit tests for native Engine NotFound helpers.""" + +from __future__ import annotations + +import pytest + +from app.ovirt.common import no_such +from app.ovirt.errors import OVirtError, fault_body +from app.ovirt.repr import generic_entity + + +def test_no_such_includes_id_in_detail() -> None: + err = no_such("host", "pve01-bad-id") + assert isinstance(err, OVirtError) + assert err.status_code == 404 + assert err.reason == "NotFound" + assert err.detail == "No such host ('pve01-bad-id')" + + +def test_no_such_fault_body_is_json_not_html() -> None: + err = no_such("datacenter", "missing-dc") + body = fault_body(err, as_xml=False) + assert body == { + "fault": {"reason": "NotFound", "detail": "No such datacenter ('missing-dc')"} + } + xml = fault_body(err, as_xml=True) + assert isinstance(xml, str) + assert "No such datacenter ('missing-dc')" in xml + assert " None: + with pytest.raises(OVirtError, match=r"No such host \('abc'\)") as caught: + generic_entity("hosts", "host", None, entity_id="abc") + assert caught.value.status_code == 404