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) };
}
+54
View File
@@ -31,6 +31,60 @@ DB-backed стаб — 501 не должен появляться для изв
base64 от `user:password`).
- Отказ по правам — попробуйте сравнить `administrator@vsphere.local` и
`readonly@vsphere.local` (см. [Авторизация](domains/authz.md)).
- В Web UI ответ 401 очищает сохранённую сессию и переключает бейдж в шапке
на **Guest** с toast «Session expired — sign in again».
## Ingress возвращает брендированный HTML 404 / nginx 405 вместо JSON
Симулятор отвечает на API-ошибки JSON (`detail` / `error_type` /
`messages`). Если вы видите HTML «page not found» сайта или голую страницу
nginx **405**, **Ingress / reverse proxy** подменил тело upstream (часто через
`custom-http-errors` у ingress-nginx).
Исправьте аннотации Ingress для этого хоста (см.
`helm/vmware-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
{
"detail": {
"error_type": "not_found",
"messages": [
{
"default_message": "No such host ('host-999')",
"id": "com.vmware.vapi.std.errors.not_found",
"args": ["host-999"]
}
],
"data": { "host": "No such host ('host-999')" }
}
}
```
Seeded id хостов для `small` / `large` / `big` начинаются с `host-11`.
Cookbook-ВМ включают `vm-101` (`web-01`).
### Корректная authenticated-мутация (vSphere Automation)
Сессия в заголовке/cookie + JSON-тело (не form-urlencoded):
```bash
SID=$(curl -sk -u 'administrator@vsphere.local:VMware1!' -X POST \
"https://HOST/api/session" | tr -d '"')
curl -sk -X POST "https://HOST/api/vcenter/vm" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-H "vmware-api-session-id: $SID" \
-d '{"name":"lab-vm","guest_os":"OTHER_GUEST_64","placement":{"folder":"group-v23","host":"host-11","datastore":"datastore-31","resource_pool":"resgroup-22"}}'
```
## Задача никогда не завершается
+53
View File
@@ -31,6 +31,59 @@ should not appear for a known path. If you see it:
`user:password` base64).
- Privilege denial — try `administrator@vsphere.local` vs
`readonly@vsphere.local` to compare (see [Authorization](domains/authz.md)).
- In the Web UI, a 401 clears the stored session and switches the header badge
to **Guest** with a “Session expired — sign in again” toast.
## Ingress returns branded HTML 404 / nginx 405 instead of JSON
The simulator answers API errors as JSON (`detail` / `error_type` /
`messages`). 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/vmware-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
{
"detail": {
"error_type": "not_found",
"messages": [
{
"default_message": "No such host ('host-999')",
"id": "com.vmware.vapi.std.errors.not_found",
"args": ["host-999"]
}
],
"data": { "host": "No such host ('host-999')" }
}
}
```
Seeded host ids for `small` / `large` / `big` start at `host-11`. Cookbook VMs
include `vm-101` (`web-01`).
### Correct authenticated mutation (vSphere Automation)
Session cookie/header + JSON body (not form-urlencoded):
```bash
SID=$(curl -sk -u 'administrator@vsphere.local:VMware1!' -X POST \
"https://HOST/api/session" | tr -d '"')
curl -sk -X POST "https://HOST/api/vcenter/vm" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-H "vmware-api-session-id: $SID" \
-d '{"name":"lab-vm","guest_os":"OTHER_GUEST_64","placement":{"folder":"group-v23","host":"host-11","datastore":"datastore-31","resource_pool":"resgroup-22"}}'
```
## Task never finishes
@@ -32,6 +32,13 @@ seed:
ingress:
enabled: true
className: nginx
# Keep API JSON 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: vmware-sim.example.com
paths:
+22
View File
@@ -0,0 +1,22 @@
"""vSphere not-found helpers expose the missing id in the message."""
from __future__ import annotations
from app.vsphere.errors import no_such, not_found
def test_no_such_includes_resource_id() -> None:
error = no_such("host", "host-999")
assert error.status_code == 404
detail = error.detail
assert isinstance(detail, dict)
assert detail["error_type"] == "not_found"
message = detail["messages"][0]
assert message["default_message"] == "No such host ('host-999')"
assert message["args"] == ["host-999"]
assert detail["data"]["host"] == "No such host ('host-999')"
def test_not_found_accepts_args() -> None:
error = not_found("Guest file not found: /tmp/x", args=["/tmp/x"])
assert error.detail["messages"][0]["args"] == ["/tmp/x"]