Return native Engine NotFound faults and clear Web UI session on 401.
Include the bad id in host/datacenter/cluster errors, expire stale auth in the console, and document Ingress annotations that preserve fault bodies.
This commit is contained in:
@@ -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
|
||||||
+9
-3
@@ -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:
|
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"])
|
oid = str(row["id"])
|
||||||
data = _data(row)
|
data = _data(row)
|
||||||
entity = {
|
entity = {
|
||||||
|
|||||||
+21
-23
@@ -11,6 +11,7 @@ from uuid import uuid4
|
|||||||
from asyncpg import Connection
|
from asyncpg import Connection
|
||||||
from fastapi import APIRouter, Request, Response
|
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.deps import get_db, require_auth
|
||||||
from app.ovirt.errors import OVirtError
|
from app.ovirt.errors import OVirtError
|
||||||
from app.ovirt.jobs import create_job, respond_action
|
from app.ovirt.jobs import create_job, respond_action
|
||||||
@@ -170,7 +171,7 @@ async def handle_engine_request(request: Request) -> Response:
|
|||||||
raise
|
raise
|
||||||
except (TypeError, AttributeError) as exc:
|
except (TypeError, AttributeError) as exc:
|
||||||
# Missing rows often crash in *_entity builders (row is None).
|
# 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:
|
except Exception as exc:
|
||||||
# Integrity / UUID parse failures from coverage mutations → client error.
|
# Integrity / UUID parse failures from coverage mutations → client error.
|
||||||
name = type(exc).__name__
|
name = type(exc).__name__
|
||||||
@@ -1045,13 +1046,12 @@ async def _handle_hosts(
|
|||||||
row = await conn.fetchrow("SELECT * FROM ov_hosts WHERE id=$1", hid)
|
row = await conn.fetchrow("SELECT * FROM ov_hosts WHERE id=$1", hid)
|
||||||
return respond(request, element="host", data=host_entity(row), status_code=201)
|
return respond(request, element="host", data=host_entity(row), status_code=201)
|
||||||
host_id = parts[1]
|
host_id = parts[1]
|
||||||
row = await conn.fetchrow("SELECT * FROM ov_hosts WHERE id=$1::uuid", host_id)
|
|
||||||
if len(parts) == 2:
|
if len(parts) == 2:
|
||||||
if method == "GET":
|
if method == "GET":
|
||||||
if row is None:
|
row = await require_host(conn, host_id)
|
||||||
raise OVirtError("NotFound", "host not found", status_code=404)
|
|
||||||
return respond(request, element="host", data=host_entity(row))
|
return respond(request, element="host", data=host_entity(row))
|
||||||
if method == "PUT":
|
if method == "PUT":
|
||||||
|
await require_host(conn, host_id)
|
||||||
body = unwrap_entity(payload, "host")
|
body = unwrap_entity(payload, "host")
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"UPDATE ov_hosts SET name=COALESCE($2,name), address=COALESCE($3,address), updated_at=now() WHERE id=$1::uuid",
|
"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("name"),
|
||||||
body.get("address"),
|
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))
|
return respond(request, element="host", data=host_entity(row))
|
||||||
if method == "DELETE":
|
if method == "DELETE":
|
||||||
|
await require_host(conn, host_id)
|
||||||
await conn.execute("DELETE FROM ov_hosts WHERE id=$1::uuid", host_id)
|
await conn.execute("DELETE FROM ov_hosts WHERE id=$1::uuid", host_id)
|
||||||
return Response(status_code=200)
|
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":
|
if len(parts) == 3 and method == "POST":
|
||||||
action = parts[2].lower()
|
action = parts[2].lower()
|
||||||
status_map = await option_json(conn, OPT_HOST_ACTION_STATUS_MAP)
|
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)
|
return respond(request, element="data_center", data=datacenter_entity(row), status_code=201)
|
||||||
dc_id = parts[1]
|
dc_id = parts[1]
|
||||||
if len(parts) == 2 and method == "GET":
|
if len(parts) == 2 and method == "GET":
|
||||||
row = await conn.fetchrow("SELECT * FROM ov_datacenters WHERE id=$1::uuid", dc_id)
|
row = await require_datacenter(conn, dc_id)
|
||||||
if row is None:
|
|
||||||
raise OVirtError("NotFound", "datacenter not found", status_code=404)
|
|
||||||
return respond(request, element="data_center", data=datacenter_entity(row))
|
return respond(request, element="data_center", data=datacenter_entity(row))
|
||||||
if len(parts) == 2 and method == "PUT":
|
if len(parts) == 2 and method == "PUT":
|
||||||
|
await require_datacenter(conn, dc_id)
|
||||||
body = unwrap_entity(payload, "data_center")
|
body = unwrap_entity(payload, "data_center")
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"UPDATE ov_datacenters SET name=COALESCE($2,name), description=COALESCE($3,description), updated_at=now() WHERE id=$1::uuid",
|
"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("name"),
|
||||||
body.get("description"),
|
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))
|
return respond(request, element="data_center", data=datacenter_entity(row))
|
||||||
if len(parts) == 2 and method == "DELETE":
|
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)
|
await conn.execute("DELETE FROM ov_datacenters WHERE id=$1::uuid", dc_id)
|
||||||
return Response(status_code=200)
|
return Response(status_code=200)
|
||||||
if len(parts) == 3 and method == "POST" and parts[2] == "cleanfinishedtasks":
|
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]
|
request: Request, conn: Connection, method: str, parts: list[str], payload: dict[str, Any]
|
||||||
) -> Response:
|
) -> Response:
|
||||||
dc_id = parts[1]
|
dc_id = parts[1]
|
||||||
dc = await conn.fetchrow("SELECT id FROM ov_datacenters WHERE id=$1::uuid", dc_id)
|
await require_datacenter(conn, dc_id)
|
||||||
if dc is None:
|
|
||||||
raise OVirtError("NotFound", "datacenter not found", status_code=404)
|
|
||||||
if len(parts) == 3 and method == "GET":
|
if len(parts) == 3 and method == "GET":
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
"SELECT * FROM ov_clusters WHERE datacenter_id=$1::uuid ORDER BY name", dc_id
|
"SELECT * FROM ov_clusters WHERE datacenter_id=$1::uuid ORDER BY name", dc_id
|
||||||
@@ -1263,7 +1265,7 @@ async def _dc_clusters(
|
|||||||
dc_id,
|
dc_id,
|
||||||
)
|
)
|
||||||
if row is None:
|
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))
|
return respond(request, element="cluster", data=cluster_entity(row))
|
||||||
if len(parts) == 4 and method == "DELETE":
|
if len(parts) == 4 and method == "DELETE":
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
@@ -1279,9 +1281,7 @@ async def _dc_networks(
|
|||||||
request: Request, conn: Connection, method: str, parts: list[str], payload: dict[str, Any]
|
request: Request, conn: Connection, method: str, parts: list[str], payload: dict[str, Any]
|
||||||
) -> Response:
|
) -> Response:
|
||||||
dc_id = parts[1]
|
dc_id = parts[1]
|
||||||
dc = await conn.fetchrow("SELECT id FROM ov_datacenters WHERE id=$1::uuid", dc_id)
|
await require_datacenter(conn, dc_id)
|
||||||
if dc is None:
|
|
||||||
raise OVirtError("NotFound", "datacenter not found", status_code=404)
|
|
||||||
if len(parts) == 3 and method == "GET":
|
if len(parts) == 3 and method == "GET":
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
"SELECT * FROM ov_networks WHERE datacenter_id=$1::uuid ORDER BY name", dc_id
|
"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)
|
return respond(request, element="cluster", data=cluster_entity(row), status_code=201)
|
||||||
cluster_id = parts[1]
|
cluster_id = parts[1]
|
||||||
if len(parts) == 2 and method == "GET":
|
if len(parts) == 2 and method == "GET":
|
||||||
row = await conn.fetchrow("SELECT * FROM ov_clusters WHERE id=$1::uuid", cluster_id)
|
row = await require_cluster(conn, cluster_id)
|
||||||
if row is None:
|
|
||||||
raise OVirtError("NotFound", "cluster not found", status_code=404)
|
|
||||||
return respond(request, element="cluster", data=cluster_entity(row))
|
return respond(request, element="cluster", data=cluster_entity(row))
|
||||||
if len(parts) == 2 and method == "PUT":
|
if len(parts) == 2 and method == "PUT":
|
||||||
|
await require_cluster(conn, cluster_id)
|
||||||
body = unwrap_entity(payload, "cluster")
|
body = unwrap_entity(payload, "cluster")
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"UPDATE ov_clusters SET name=COALESCE($2,name), description=COALESCE($3,description) WHERE id=$1::uuid",
|
"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("name"),
|
||||||
body.get("description"),
|
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))
|
return respond(request, element="cluster", data=cluster_entity(row))
|
||||||
if len(parts) == 2 and method == "DELETE":
|
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)
|
await conn.execute("DELETE FROM ov_clusters WHERE id=$1::uuid", cluster_id)
|
||||||
return Response(status_code=200)
|
return Response(status_code=200)
|
||||||
if len(parts) >= 3 and parts[2] == "affinitygroups":
|
if len(parts) >= 3 and parts[2] == "affinitygroups":
|
||||||
@@ -1542,9 +1542,7 @@ async def _cluster_networks(
|
|||||||
|
|
||||||
del payload
|
del payload
|
||||||
cluster_id = parts[1]
|
cluster_id = parts[1]
|
||||||
cluster = await conn.fetchrow("SELECT * FROM ov_clusters WHERE id=$1::uuid", cluster_id)
|
cluster = await require_cluster(conn, cluster_id)
|
||||||
if cluster is None:
|
|
||||||
raise OVirtError("NotFound", "cluster not found", status_code=404)
|
|
||||||
dc_id = cluster["datacenter_id"]
|
dc_id = cluster["datacenter_id"]
|
||||||
if len(parts) == 3 and method == "GET":
|
if len(parts) == 3 and method == "GET":
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
|
|||||||
@@ -123,11 +123,19 @@ async def handle_generic(
|
|||||||
)
|
)
|
||||||
if method == "GET":
|
if method == "GET":
|
||||||
if row is None:
|
if row is None:
|
||||||
raise OVirtError("NotFound", f"{element} not found", status_code=404)
|
from app.ovirt.common import no_such
|
||||||
return respond(request, element=element, data=generic_entity(collection, element, row))
|
|
||||||
|
raise no_such(element, oid)
|
||||||
|
return respond(
|
||||||
|
request,
|
||||||
|
element=element,
|
||||||
|
data=generic_entity(collection, element, row, entity_id=oid),
|
||||||
|
)
|
||||||
if method == "PUT":
|
if method == "PUT":
|
||||||
if row is None:
|
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)
|
body = unwrap_entity(payload, element)
|
||||||
data = dict(json.loads(row["data"]) if isinstance(row["data"], str) else row["data"] or {})
|
data = dict(json.loads(row["data"]) if isinstance(row["data"], str) else row["data"] or {})
|
||||||
data.update(body)
|
data.update(body)
|
||||||
@@ -330,8 +338,10 @@ async def handle_subcollection(
|
|||||||
parent_id,
|
parent_id,
|
||||||
)
|
)
|
||||||
if row is None:
|
if row is None:
|
||||||
raise OVirtError("NotFound", f"{element} not found", status_code=404)
|
from app.ovirt.common import no_such
|
||||||
data = generic_entity(sub, element, row)
|
|
||||||
|
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}"
|
data["href"] = f"/ovirt-engine/api/{parent_collection}/{parent_id}/{sub}/{oid}"
|
||||||
return respond(request, element=element, data=data)
|
return respond(request, element=element, data=data)
|
||||||
if method == "DELETE":
|
if method == "DELETE":
|
||||||
|
|||||||
+30
-15
@@ -4740,12 +4740,7 @@
|
|||||||
if (!res.ok) throw new Error("session expired");
|
if (!res.ok) throw new Error("session expired");
|
||||||
persistAuth();
|
persistAuth();
|
||||||
} catch {
|
} catch {
|
||||||
state.ticket = null;
|
clearSession({ closePanel: false });
|
||||||
state.csrf = null;
|
|
||||||
state.username = null;
|
|
||||||
state.project = null;
|
|
||||||
localStorage.removeItem(LS_AUTH);
|
|
||||||
setAuth(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() {
|
function logout() {
|
||||||
const token = state.ticket;
|
const token = state.ticket;
|
||||||
if (token) {
|
if (token) {
|
||||||
@@ -6002,15 +6014,15 @@
|
|||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
state.ticket = null;
|
clearSession({ toastMessage: "Signed out", toastKind: "info" });
|
||||||
state.csrf = null;
|
}
|
||||||
state.username = null;
|
|
||||||
state.project = null;
|
function expireSession() {
|
||||||
setAuth(false);
|
clearSession({
|
||||||
persistAuth();
|
toastMessage: "Session expired — sign in again",
|
||||||
closeAuthPanel();
|
toastKind: "warn",
|
||||||
resetClusterStats("—");
|
closePanel: false,
|
||||||
toast("Signed out", "info");
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateStatusBadge(status) {
|
function updateStatusBadge(status) {
|
||||||
@@ -6087,6 +6099,9 @@
|
|||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
let parsed;
|
let parsed;
|
||||||
try { parsed = JSON.parse(text); } catch { parsed = text; }
|
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) };
|
return { status: res.status, body: parsed, durationMs: Math.round(performance.now() - started) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,8 +73,11 @@ See [Kubernetes / Helm](kubernetes.md) and
|
|||||||
| `secrets.ticketSigningKey` | Must be rotated for shared clusters |
|
| `secrets.ticketSigningKey` | Must be rotated for shared clusters |
|
||||||
| `service.port` | ClusterIP port (default `8080`) |
|
| `service.port` | ClusterIP port (default `8080`) |
|
||||||
|
|
||||||
`gateway.*` / `ingress.*` in `values.yaml` are reserved; the chart currently
|
`gateway.*` is unused (no Compose-style nginx gateway in the chart).
|
||||||
exposes FastAPI directly (no Compose-style nginx gateway).
|
`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
|
## Contract packs
|
||||||
|
|
||||||
|
|||||||
+6
-3
@@ -19,9 +19,12 @@ Published image (when pushed):
|
|||||||
| **seed** Job (optional) | `minimal` or `demo` lab data |
|
| **seed** Job (optional) | `minimal` or `demo` lab data |
|
||||||
|
|
||||||
> The Compose stack publishes Engine HTTPS + UI via nginx `api-gateway`
|
> The Compose stack publishes Engine HTTPS + UI via nginx `api-gateway`
|
||||||
> ([ports.md](ports.md)). The Helm chart **does not yet** ship that gateway:
|
> ([ports.md](ports.md)). The Helm chart serves the simulator Service on `:8080`.
|
||||||
> `gateway.*` / `ingress.*` keys in `values.yaml` are reserved and unused.
|
> Optional Ingress: enable with
|
||||||
> Access the simulator Service on `:8080` (port-forward or your own Ingress).
|
> [`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
|
## Prerequisites
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -73,8 +73,11 @@ Docker Compose подставляет многие из них для серви
|
|||||||
| `secrets.ticketSigningKey` | Нужно ротировать на общих кластерах |
|
| `secrets.ticketSigningKey` | Нужно ротировать на общих кластерах |
|
||||||
| `service.port` | Порт ClusterIP (по умолчанию `8080`) |
|
| `service.port` | Порт ClusterIP (по умолчанию `8080`) |
|
||||||
|
|
||||||
`gateway.*` / `ingress.*` в `values.yaml` зарезервированы; чарт сейчас отдаёт
|
`gateway.*` не используется (нет nginx gateway как в Compose).
|
||||||
FastAPI напрямую (без nginx gateway как в Compose).
|
`ingress.*` опционален — см.
|
||||||
|
[`values-ingress-example.yaml`](../../helm/ovirt-api-simulator/values-ingress-example.yaml)
|
||||||
|
и [Устранение неполадок](troubleshooting.md) для annotations, сохраняющих
|
||||||
|
тела fault Engine.
|
||||||
|
|
||||||
## Контрактные packs
|
## Контрактные packs
|
||||||
|
|
||||||
|
|||||||
@@ -19,10 +19,12 @@
|
|||||||
| **seed** Job (опционально) | Лабораторные данные `minimal` или `demo` |
|
| **seed** Job (опционально) | Лабораторные данные `minimal` или `demo` |
|
||||||
|
|
||||||
> Compose публикует Engine HTTPS + UI через nginx `api-gateway`
|
> Compose публикует Engine HTTPS + UI через nginx `api-gateway`
|
||||||
> ([ports.md](ports.md)). Helm-чарт **пока не** включает этот gateway:
|
> ([ports.md](ports.md)). Helm-чарт отдаёт Service симулятора на `:8080`.
|
||||||
> ключи `gateway.*` / `ingress.*` в `values.yaml` зарезервированы и не
|
> Опциональный Ingress:
|
||||||
> используются. Доступ — к Service симулятора на `:8080` (port-forward или
|
> [`values-ingress-example.yaml`](../../helm/ovirt-api-simulator/values-ingress-example.yaml)
|
||||||
> свой Ingress).
|
> (`proxy-intercept-errors: "false"`, `custom-http-errors: "502,503"`, чтобы
|
||||||
|
> fault Engine не подменялся брендированным HTML). См.
|
||||||
|
> [Устранение неполадок](troubleshooting.md).
|
||||||
|
|
||||||
## Требования
|
## Требования
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,49 @@ make seed-demo
|
|||||||
Проверьте, что `OVIRT_SERIES` соответствует ожидаемому pack
|
Проверьте, что `OVIRT_SERIES` соответствует ожидаемому pack
|
||||||
([api-versions.md](api-versions.md)).
|
([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
|
## Падения клиентских suites
|
||||||
|
|
||||||
Убедитесь, что стек поднят и засеян, затем сначала smoke:
|
Убедитесь, что стек поднят и засеян, затем сначала smoke:
|
||||||
|
|||||||
@@ -36,6 +36,49 @@ make seed-demo
|
|||||||
Set `Version: 4` (or `3`) or use `/ovirt-engine/api/v4/...`. Confirm
|
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)).
|
`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
|
## Client suite failures
|
||||||
|
|
||||||
Ensure the stack is up and seeded, then run smoke first:
|
Ensure the stack is up and seeded, then run smoke first:
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ helm upgrade --install ovirt-sim . \
|
|||||||
--set secrets.ticketSigningKey="$(openssl rand -hex 32)"
|
--set secrets.ticketSigningKey="$(openssl rand -hex 32)"
|
||||||
```
|
```
|
||||||
|
|
||||||
Access via port-forward to Service `:8080` (Engine API, SSO, Web UI, `/docs`).
|
Access via port-forward to Service `:8080` (Engine API, SSO, Web UI, `/docs`),
|
||||||
The Compose nginx gateway is **not** part of this chart yet.
|
or enable Ingress with
|
||||||
|
[`values-ingress-example.yaml`](values-ingress-example.yaml).
|
||||||
|
|
||||||
Full guide: [docs/kubernetes.md](../../docs/kubernetes.md).
|
Full guide: [docs/kubernetes.md](../../docs/kubernetes.md).
|
||||||
Values: [`values.yaml`](values.yaml).
|
Values: [`values.yaml`](values.yaml).
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ helm upgrade --install ovirt-sim . \
|
|||||||
--set secrets.ticketSigningKey="$(openssl rand -hex 32)"
|
--set secrets.ticketSigningKey="$(openssl rand -hex 32)"
|
||||||
```
|
```
|
||||||
|
|
||||||
Доступ через port-forward к Service `:8080` (Engine API, SSO, Web UI, `/docs`).
|
Доступ через port-forward к Service `:8080` (Engine API, SSO, Web UI, `/docs`)
|
||||||
Nginx gateway из Compose в этот чарт **пока не** входит.
|
или через Ingress с
|
||||||
|
[`values-ingress-example.yaml`](values-ingress-example.yaml).
|
||||||
|
|
||||||
Полное руководство: [docs/ru/kubernetes.md](../../docs/ru/kubernetes.md).
|
Полное руководство: [docs/ru/kubernetes.md](../../docs/ru/kubernetes.md).
|
||||||
Values: [`values.yaml`](values.yaml).
|
Values: [`values.yaml`](values.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 }}
|
||||||
@@ -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: []
|
||||||
@@ -20,11 +20,16 @@ gateway:
|
|||||||
ingress:
|
ingress:
|
||||||
enabled: false
|
enabled: false
|
||||||
className: nginx
|
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:
|
hosts:
|
||||||
- host: ovirt-engine.local
|
- host: ovirt-engine.local
|
||||||
paths:
|
paths:
|
||||||
- path: /
|
- path: /
|
||||||
pathType: Prefix
|
pathType: Prefix
|
||||||
|
tls: []
|
||||||
|
|
||||||
config:
|
config:
|
||||||
ovirtSeries: "4.5"
|
ovirtSeries: "4.5"
|
||||||
|
|||||||
@@ -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 "<html" not in xml.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_generic_entity_none_raises_no_such() -> 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
|
||||||
Reference in New Issue
Block a user