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:
+24
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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) };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user