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:
|
||||
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 = {
|
||||
|
||||
+21
-23
@@ -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(
|
||||
|
||||
@@ -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":
|
||||
|
||||
+30
-15
@@ -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) };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user