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
+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"):