diff --git a/app/main.py b/app/main.py index b269237..220f8d9 100644 --- a/app/main.py +++ b/app/main.py @@ -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 diff --git a/app/vsphere/domain/inventory_ops.py b/app/vsphere/domain/inventory_ops.py index e24ab6b..132a39d 100644 --- a/app/vsphere/domain/inventory_ops.py +++ b/app/vsphere/domain/inventory_ops.py @@ -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 diff --git a/app/vsphere/domain/vm_ops.py b/app/vsphere/domain/vm_ops.py index 9f86d58..9a0b109 100644 --- a/app/vsphere/domain/vm_ops.py +++ b/app/vsphere/domain/vm_ops.py @@ -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]: diff --git a/app/vsphere/errors.py b/app/vsphere/errors.py index 010e107..6162ae7 100644 --- a/app/vsphere/errors.py +++ b/app/vsphere/errors.py @@ -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, diff --git a/app/vsphere/inventory.py b/app/vsphere/inventory.py index e19dc27..b20cd62 100644 --- a/app/vsphere/inventory.py +++ b/app/vsphere/inventory.py @@ -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, *, diff --git a/app/vsphere/rest/platform_rest.py b/app/vsphere/rest/platform_rest.py index 97dde51..1cb4622 100644 --- a/app/vsphere/rest/platform_rest.py +++ b/app/vsphere/rest/platform_rest.py @@ -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] diff --git a/app/vsphere/rest/router.py b/app/vsphere/rest/router.py index f7ab6ed..362209f 100644 --- a/app/vsphere/rest/router.py +++ b/app/vsphere/rest/router.py @@ -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", diff --git a/app/vsphere/rest/stub_surface.py b/app/vsphere/rest/stub_surface.py index 22b9c6e..e5dad66 100644 --- a/app/vsphere/rest/stub_surface.py +++ b/app/vsphere/rest/stub_surface.py @@ -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"): diff --git a/app/web/index.html b/app/web/index.html index cb343be..8e99887 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -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) }; } diff --git a/docs/ru/troubleshooting.md b/docs/ru/troubleshooting.md index 6f13e4f..93d976b 100644 --- a/docs/ru/troubleshooting.md +++ b/docs/ru/troubleshooting.md @@ -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"}}' +``` ## Задача никогда не завершается diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index a0d1837..5248f68 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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 diff --git a/helm/vmware-api-simulator/values-ingress-example.yaml b/helm/vmware-api-simulator/values-ingress-example.yaml index 3812f51..8fc03d5 100644 --- a/helm/vmware-api-simulator/values-ingress-example.yaml +++ b/helm/vmware-api-simulator/values-ingress-example.yaml @@ -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: diff --git a/tests/unit/test_vsphere_errors.py b/tests/unit/test_vsphere_errors.py new file mode 100644 index 0000000..c06b8d0 --- /dev/null +++ b/tests/unit/test_vsphere_errors.py @@ -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"]