Keep API not-found and 401 responses usable behind Ingress and in the Web UI.

Return native JSON with the missing id in the message, clear stale sessions on
401, and document ingress-nginx annotations so branded HTML 404/405 pages do
not rewrite simulator bodies.
This commit is contained in:
2026-07-22 06:52:35 +03:00
parent 131e2e63d2
commit e8b08526d1
13 changed files with 260 additions and 62 deletions
+24
View File
@@ -6,6 +6,9 @@ import asyncio
from typing import cast
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.requests import Request as StarletteRequest
from app.api.errors import ApiError, api_error_handler, unhandled_exception_handler
from app.api.middleware import HeadAsGetMiddleware, RequestContextMiddleware
@@ -30,6 +33,26 @@ from app.vsphere.soap.router import router as vsphere_soap_router
from app.web.routes import router as web_router
async def _json_http_exception_handler(
_request: StarletteRequest, exc: StarletteHTTPException
) -> JSONResponse:
"""Always return JSON for HTTP errors — never HTML error pages from the app."""
detail = exc.detail
if isinstance(detail, dict):
# VsphereError and similar already carry the product envelope in detail.
content: dict | list = {"detail": detail}
elif isinstance(detail, list):
content = {"detail": detail}
else:
content = {"detail": detail}
return JSONResponse(
status_code=exc.status_code,
content=content,
media_type="application/json",
)
def create_app(
settings: Settings | None = None,
database_factory: DatabaseFactory = default_database_factory,
@@ -110,6 +133,7 @@ def create_app(
app.add_middleware(VsphereVersionGateMiddleware)
app.add_exception_handler(Exception, unhandled_exception_handler)
app.add_exception_handler(ApiError, api_error_handler)
app.add_exception_handler(StarletteHTTPException, _json_http_exception_handler)
# Native vSphere surface (survives contract hot-swap).
from app.vsphere.soap.pbm import router as vsphere_pbm_router
+1 -3
View File
@@ -218,9 +218,7 @@ def _is_seed_host_or_named_vm(moid: str, obj: Any) -> bool:
async def set_host_maintenance(database: Database, host: str, enabled: bool) -> dict[str, Any]:
obj = await inventory.get_object(database, host)
if obj is None or obj.type != "HostSystem":
raise not_found(f"Host {host} not found")
obj = await inventory.require_object(database, host, type_name="HostSystem", resource="host")
props = dict(obj.props)
props["connection_state"] = "CONNECTED"
props["maintenance_mode"] = enabled
+1 -4
View File
@@ -14,10 +14,7 @@ from app.vsphere.errors import invalid_argument, not_found
async def require_vm(database: Database, vm: str) -> inventory.ManagedObject:
obj = await inventory.get_object(database, vm)
if obj is None or obj.type != "VirtualMachine":
raise not_found(f"VM {vm} not found")
return obj
return await inventory.require_object(database, vm, type_name="VirtualMachine", resource="vm")
def _default_identity(name: str, moid: str) -> dict[str, str]:
+24 -2
View File
@@ -41,16 +41,38 @@ def unauthenticated(message: str = "Authentication required") -> VsphereError:
)
def not_found(message: str = "Not found") -> VsphereError:
def not_found(message: str = "Not found", *, args: list[Any] | None = None) -> VsphereError:
return VsphereError(
404,
error_type="not_found",
messages=[
{"default_message": message, "id": "com.vmware.vapi.std.errors.not_found", "args": []}
{
"default_message": message,
"id": "com.vmware.vapi.std.errors.not_found",
"args": list(args or []),
}
],
)
def no_such(resource: str, resource_id: str) -> VsphereError:
"""Native not-found with the missing id in the message (Ingress-friendly JSON)."""
message = f"No such {resource} ('{resource_id}')"
return VsphereError(
404,
error_type="not_found",
messages=[
{
"default_message": message,
"id": "com.vmware.vapi.std.errors.not_found",
"args": [resource_id],
}
],
data={resource: message},
)
def already_exists(message: str = "Already exists") -> VsphereError:
return VsphereError(
400,
+30
View File
@@ -60,6 +60,36 @@ async def get_object(database: Database, moid: str) -> ManagedObject | None:
return None if row is None else _row(row)
_TYPE_RESOURCE: dict[str, str] = {
"HostSystem": "host",
"VirtualMachine": "vm",
"Datastore": "datastore",
"Datacenter": "datacenter",
"Folder": "folder",
"ClusterComputeResource": "cluster",
"ResourcePool": "resource_pool",
"Network": "network",
}
async def require_object(
database: Database,
moid: str,
*,
type_name: str | None = None,
resource: str | None = None,
) -> ManagedObject:
"""Return the managed object or raise a native not-found with the bad id."""
from app.vsphere.errors import no_such
obj = await get_object(database, moid)
label = resource or (type_name and _TYPE_RESOURCE.get(type_name)) or "object"
if obj is None or (type_name is not None and obj.type != type_name):
raise no_such(label, moid)
return obj
async def upsert_object(
database: Database,
*,
+3 -9
View File
@@ -464,9 +464,7 @@ async def host_storage(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, Any]]:
obj = await inventory.get_object(database, host)
if obj is None or obj.type != "HostSystem":
raise not_found(f"Host {host} not found")
obj = await inventory.require_object(database, host, type_name="HostSystem", resource="host")
devices = obj.props.get("storage_devices")
return list(devices) if isinstance(devices, list) else []
@@ -477,9 +475,7 @@ async def host_networking(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
obj = await inventory.get_object(database, host)
if obj is None or obj.type != "HostSystem":
raise not_found(f"Host {host} not found")
obj = await inventory.require_object(database, host, type_name="HostSystem", resource="host")
networking = obj.props.get("networking")
return networking if isinstance(networking, dict) else {}
@@ -490,9 +486,7 @@ async def folder_children(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> list[dict[str, str]]:
parent = await inventory.get_object(database, folder)
if parent is None:
raise not_found(f"Folder {folder} not found")
await inventory.require_object(database, folder, resource="folder")
children = [obj for obj in await inventory.list_objects(database) if obj.parent_moid == folder]
return [{"moid": c.moid, "type": c.type, "name": c.name} for c in children]
+9 -19
View File
@@ -12,7 +12,7 @@ from app.db.pool import Database
from app.dependencies import get_database
from app.vsphere import inventory
from app.vsphere.domain import vm_ops
from app.vsphere.errors import invalid_argument, not_found, unauthenticated
from app.vsphere.errors import invalid_argument, unauthenticated
from app.vsphere.rest import mappers
from app.vsphere.security.authz import require_privilege, require_read
from app.vsphere.security.session import (
@@ -200,9 +200,7 @@ async def get_vm(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
obj = await inventory.get_object(database, vm)
if obj is None or obj.type != "VirtualMachine":
raise not_found(f"VM {vm} not found")
obj = await inventory.require_object(database, vm, type_name="VirtualMachine", resource="vm")
return mappers.vm_info(obj)
@@ -286,9 +284,7 @@ async def delete_vm(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_privilege("VirtualMachine.Inventory.Delete")),
) -> Response:
obj = await inventory.get_object(database, vm)
if obj is None or obj.type != "VirtualMachine":
raise not_found(f"VM {vm} not found")
obj = await inventory.require_object(database, vm, type_name="VirtualMachine", resource="vm")
if obj.props.get("power_state") == "POWERED_ON":
raise invalid_argument("VM must be powered off before delete")
await inventory.delete_object(database, vm)
@@ -301,9 +297,7 @@ async def get_vm_power(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, str]:
obj = await inventory.get_object(database, vm)
if obj is None or obj.type != "VirtualMachine":
raise not_found(f"VM {vm} not found")
obj = await inventory.require_object(database, vm, type_name="VirtualMachine", resource="vm")
state = str(obj.props.get("power_state") or "POWERED_OFF")
return {"state": state}
@@ -334,9 +328,7 @@ async def get_host(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
obj = await inventory.get_object(database, host)
if obj is None or obj.type != "HostSystem":
raise not_found(f"Host {host} not found")
obj = await inventory.require_object(database, host, type_name="HostSystem", resource="host")
return mappers.host_info(obj)
@@ -355,9 +347,9 @@ async def get_datastore(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
obj = await inventory.get_object(database, datastore)
if obj is None or obj.type != "Datastore":
raise not_found(f"Datastore {datastore} not found")
obj = await inventory.require_object(
database, datastore, type_name="Datastore", resource="datastore"
)
props = obj.props
return {
"name": obj.name,
@@ -421,9 +413,7 @@ async def vm_guest_identity(
database: Database = Depends(get_database),
_: SessionInfo = Depends(require_read),
) -> dict[str, Any]:
obj = await inventory.get_object(database, vm)
if obj is None or obj.type != "VirtualMachine":
raise not_found(f"VM {vm} not found")
obj = await inventory.require_object(database, vm, type_name="VirtualMachine", resource="vm")
return {
"name": obj.name,
"family": "LINUX",
+5 -16
View File
@@ -19,7 +19,6 @@ from app.dependencies import get_database
from app.vsphere import inventory
from app.vsphere.domain import api_state, tagging
from app.vsphere.domain import content as content_domain
from app.vsphere.errors import not_found
from app.vsphere.rest.coverage import IMPLEMENTED
from app.vsphere.security.authz import require_read
from app.vsphere.security.session import SessionInfo
@@ -61,9 +60,7 @@ async def _live_get(database: Database, template: str, concrete: str) -> Any | N
vm = params.get("vm")
if vm and "/hardware/" in template:
obj = await inventory.get_object(database, vm)
if obj is None:
raise not_found(f"VM {vm} not found")
obj = await inventory.require_object(database, vm, type_name="VirtualMachine", resource="vm")
props = obj.props or {}
def _live_list(key: str) -> list[Any] | None:
@@ -107,9 +104,7 @@ async def _live_get(database: Database, template: str, concrete: str) -> Any | N
return nic
if vm and "/guest/" in template:
obj = await inventory.get_object(database, vm)
if obj is None:
raise not_found(f"VM {vm} not found")
obj = await inventory.require_object(database, vm, type_name="VirtualMachine", resource="vm")
props = obj.props or {}
if template.endswith("/guest/local-filesystem"):
if "guest_filesystems" in props:
@@ -130,15 +125,11 @@ async def _live_get(database: Database, template: str, concrete: str) -> Any | N
host = params.get("host")
if host and template.endswith("/networking"):
obj = await inventory.get_object(database, host)
if obj is None:
raise not_found(f"Host {host} not found")
obj = await inventory.require_object(database, host, type_name="HostSystem", resource="host")
networking = (obj.props or {}).get("networking")
return networking if networking is not None else None
if host and "storage-device" in template:
obj = await inventory.get_object(database, host)
if obj is None:
raise not_found(f"Host {host} not found")
obj = await inventory.require_object(database, host, type_name="HostSystem", resource="host")
devices = (obj.props or {}).get("storage_devices")
return devices if devices is not None else None
@@ -156,9 +147,7 @@ async def _mutate_vm_hardware(
vm = params.get("vm")
if not vm or "/hardware/" not in template:
return False
obj = await inventory.get_object(database, vm)
if obj is None:
raise not_found(f"VM {vm} not found")
obj = await inventory.require_object(database, vm, type_name="VirtualMachine", resource="vm")
props = dict(obj.props or {})
if verb == "POST" and template.endswith("/hardware/cdrom"):
+27 -9
View File
@@ -6228,6 +6228,21 @@
}
}
async function clearSession(options = {}) {
const { toastMessage = null, toastKind = "info", closePanel = true } = options;
state.ticket = null;
state.csrf = null;
state.username = null;
document.cookie = "vmware-api-session-id=; Max-Age=0; path=/; SameSite=Strict";
try { localStorage.removeItem(LS_AUTH); } catch { /* ignore */ }
try { sessionStorage.removeItem(LS_AUTH); } catch { /* ignore */ }
setAuth(false);
persistAuth();
if (closePanel) closeAuthPanel();
resetClusterStats("—");
if (toastMessage) toast(toastMessage, toastKind);
}
async function logout() {
try {
if (state.ticket) {
@@ -6240,15 +6255,15 @@
} catch (error) {
console.warn(error);
}
state.ticket = null;
state.csrf = null;
state.username = null;
document.cookie = "vmware-api-session-id=; Max-Age=0; path=/; SameSite=Strict";
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) {
@@ -6340,6 +6355,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) };
}