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 typing import cast
|
||||||
|
|
||||||
from fastapi import FastAPI
|
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.errors import ApiError, api_error_handler, unhandled_exception_handler
|
||||||
from app.api.middleware import HeadAsGetMiddleware, RequestContextMiddleware
|
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
|
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(
|
def create_app(
|
||||||
settings: Settings | None = None,
|
settings: Settings | None = None,
|
||||||
database_factory: DatabaseFactory = default_database_factory,
|
database_factory: DatabaseFactory = default_database_factory,
|
||||||
@@ -110,6 +133,7 @@ def create_app(
|
|||||||
app.add_middleware(VsphereVersionGateMiddleware)
|
app.add_middleware(VsphereVersionGateMiddleware)
|
||||||
app.add_exception_handler(Exception, unhandled_exception_handler)
|
app.add_exception_handler(Exception, unhandled_exception_handler)
|
||||||
app.add_exception_handler(ApiError, api_error_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).
|
# Native vSphere surface (survives contract hot-swap).
|
||||||
from app.vsphere.soap.pbm import router as vsphere_pbm_router
|
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]:
|
async def set_host_maintenance(database: Database, host: str, enabled: bool) -> dict[str, Any]:
|
||||||
obj = await inventory.get_object(database, host)
|
obj = await inventory.require_object(database, host, type_name="HostSystem", resource="host")
|
||||||
if obj is None or obj.type != "HostSystem":
|
|
||||||
raise not_found(f"Host {host} not found")
|
|
||||||
props = dict(obj.props)
|
props = dict(obj.props)
|
||||||
props["connection_state"] = "CONNECTED"
|
props["connection_state"] = "CONNECTED"
|
||||||
props["maintenance_mode"] = enabled
|
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:
|
async def require_vm(database: Database, vm: str) -> inventory.ManagedObject:
|
||||||
obj = await inventory.get_object(database, vm)
|
return await inventory.require_object(database, vm, type_name="VirtualMachine", resource="vm")
|
||||||
if obj is None or obj.type != "VirtualMachine":
|
|
||||||
raise not_found(f"VM {vm} not found")
|
|
||||||
return obj
|
|
||||||
|
|
||||||
|
|
||||||
def _default_identity(name: str, moid: str) -> dict[str, str]:
|
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(
|
return VsphereError(
|
||||||
404,
|
404,
|
||||||
error_type="not_found",
|
error_type="not_found",
|
||||||
messages=[
|
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:
|
def already_exists(message: str = "Already exists") -> VsphereError:
|
||||||
return VsphereError(
|
return VsphereError(
|
||||||
400,
|
400,
|
||||||
|
|||||||
@@ -60,6 +60,36 @@ async def get_object(database: Database, moid: str) -> ManagedObject | None:
|
|||||||
return None if row is None else _row(row)
|
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(
|
async def upsert_object(
|
||||||
database: Database,
|
database: Database,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -464,9 +464,7 @@ async def host_storage(
|
|||||||
database: Database = Depends(get_database),
|
database: Database = Depends(get_database),
|
||||||
_: SessionInfo = Depends(require_read),
|
_: SessionInfo = Depends(require_read),
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
obj = await inventory.get_object(database, host)
|
obj = await inventory.require_object(database, host, type_name="HostSystem", resource="host")
|
||||||
if obj is None or obj.type != "HostSystem":
|
|
||||||
raise not_found(f"Host {host} not found")
|
|
||||||
devices = obj.props.get("storage_devices")
|
devices = obj.props.get("storage_devices")
|
||||||
return list(devices) if isinstance(devices, list) else []
|
return list(devices) if isinstance(devices, list) else []
|
||||||
|
|
||||||
@@ -477,9 +475,7 @@ async def host_networking(
|
|||||||
database: Database = Depends(get_database),
|
database: Database = Depends(get_database),
|
||||||
_: SessionInfo = Depends(require_read),
|
_: SessionInfo = Depends(require_read),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
obj = await inventory.get_object(database, host)
|
obj = await inventory.require_object(database, host, type_name="HostSystem", resource="host")
|
||||||
if obj is None or obj.type != "HostSystem":
|
|
||||||
raise not_found(f"Host {host} not found")
|
|
||||||
networking = obj.props.get("networking")
|
networking = obj.props.get("networking")
|
||||||
return networking if isinstance(networking, dict) else {}
|
return networking if isinstance(networking, dict) else {}
|
||||||
|
|
||||||
@@ -490,9 +486,7 @@ async def folder_children(
|
|||||||
database: Database = Depends(get_database),
|
database: Database = Depends(get_database),
|
||||||
_: SessionInfo = Depends(require_read),
|
_: SessionInfo = Depends(require_read),
|
||||||
) -> list[dict[str, str]]:
|
) -> list[dict[str, str]]:
|
||||||
parent = await inventory.get_object(database, folder)
|
await inventory.require_object(database, folder, resource="folder")
|
||||||
if parent is None:
|
|
||||||
raise not_found(f"Folder {folder} not found")
|
|
||||||
children = [obj for obj in await inventory.list_objects(database) if obj.parent_moid == 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]
|
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.dependencies import get_database
|
||||||
from app.vsphere import inventory
|
from app.vsphere import inventory
|
||||||
from app.vsphere.domain import vm_ops
|
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.rest import mappers
|
||||||
from app.vsphere.security.authz import require_privilege, require_read
|
from app.vsphere.security.authz import require_privilege, require_read
|
||||||
from app.vsphere.security.session import (
|
from app.vsphere.security.session import (
|
||||||
@@ -200,9 +200,7 @@ async def get_vm(
|
|||||||
database: Database = Depends(get_database),
|
database: Database = Depends(get_database),
|
||||||
_: SessionInfo = Depends(require_read),
|
_: SessionInfo = Depends(require_read),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
obj = await inventory.get_object(database, vm)
|
obj = await inventory.require_object(database, vm, type_name="VirtualMachine", resource="vm")
|
||||||
if obj is None or obj.type != "VirtualMachine":
|
|
||||||
raise not_found(f"VM {vm} not found")
|
|
||||||
return mappers.vm_info(obj)
|
return mappers.vm_info(obj)
|
||||||
|
|
||||||
|
|
||||||
@@ -286,9 +284,7 @@ async def delete_vm(
|
|||||||
database: Database = Depends(get_database),
|
database: Database = Depends(get_database),
|
||||||
_: SessionInfo = Depends(require_privilege("VirtualMachine.Inventory.Delete")),
|
_: SessionInfo = Depends(require_privilege("VirtualMachine.Inventory.Delete")),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
obj = await inventory.get_object(database, vm)
|
obj = await inventory.require_object(database, vm, type_name="VirtualMachine", resource="vm")
|
||||||
if obj is None or obj.type != "VirtualMachine":
|
|
||||||
raise not_found(f"VM {vm} not found")
|
|
||||||
if obj.props.get("power_state") == "POWERED_ON":
|
if obj.props.get("power_state") == "POWERED_ON":
|
||||||
raise invalid_argument("VM must be powered off before delete")
|
raise invalid_argument("VM must be powered off before delete")
|
||||||
await inventory.delete_object(database, vm)
|
await inventory.delete_object(database, vm)
|
||||||
@@ -301,9 +297,7 @@ async def get_vm_power(
|
|||||||
database: Database = Depends(get_database),
|
database: Database = Depends(get_database),
|
||||||
_: SessionInfo = Depends(require_read),
|
_: SessionInfo = Depends(require_read),
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
obj = await inventory.get_object(database, vm)
|
obj = await inventory.require_object(database, vm, type_name="VirtualMachine", resource="vm")
|
||||||
if obj is None or obj.type != "VirtualMachine":
|
|
||||||
raise not_found(f"VM {vm} not found")
|
|
||||||
state = str(obj.props.get("power_state") or "POWERED_OFF")
|
state = str(obj.props.get("power_state") or "POWERED_OFF")
|
||||||
return {"state": state}
|
return {"state": state}
|
||||||
|
|
||||||
@@ -334,9 +328,7 @@ async def get_host(
|
|||||||
database: Database = Depends(get_database),
|
database: Database = Depends(get_database),
|
||||||
_: SessionInfo = Depends(require_read),
|
_: SessionInfo = Depends(require_read),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
obj = await inventory.get_object(database, host)
|
obj = await inventory.require_object(database, host, type_name="HostSystem", resource="host")
|
||||||
if obj is None or obj.type != "HostSystem":
|
|
||||||
raise not_found(f"Host {host} not found")
|
|
||||||
return mappers.host_info(obj)
|
return mappers.host_info(obj)
|
||||||
|
|
||||||
|
|
||||||
@@ -355,9 +347,9 @@ async def get_datastore(
|
|||||||
database: Database = Depends(get_database),
|
database: Database = Depends(get_database),
|
||||||
_: SessionInfo = Depends(require_read),
|
_: SessionInfo = Depends(require_read),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
obj = await inventory.get_object(database, datastore)
|
obj = await inventory.require_object(
|
||||||
if obj is None or obj.type != "Datastore":
|
database, datastore, type_name="Datastore", resource="datastore"
|
||||||
raise not_found(f"Datastore {datastore} not found")
|
)
|
||||||
props = obj.props
|
props = obj.props
|
||||||
return {
|
return {
|
||||||
"name": obj.name,
|
"name": obj.name,
|
||||||
@@ -421,9 +413,7 @@ async def vm_guest_identity(
|
|||||||
database: Database = Depends(get_database),
|
database: Database = Depends(get_database),
|
||||||
_: SessionInfo = Depends(require_read),
|
_: SessionInfo = Depends(require_read),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
obj = await inventory.get_object(database, vm)
|
obj = await inventory.require_object(database, vm, type_name="VirtualMachine", resource="vm")
|
||||||
if obj is None or obj.type != "VirtualMachine":
|
|
||||||
raise not_found(f"VM {vm} not found")
|
|
||||||
return {
|
return {
|
||||||
"name": obj.name,
|
"name": obj.name,
|
||||||
"family": "LINUX",
|
"family": "LINUX",
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ from app.dependencies import get_database
|
|||||||
from app.vsphere import inventory
|
from app.vsphere import inventory
|
||||||
from app.vsphere.domain import api_state, tagging
|
from app.vsphere.domain import api_state, tagging
|
||||||
from app.vsphere.domain import content as content_domain
|
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.rest.coverage import IMPLEMENTED
|
||||||
from app.vsphere.security.authz import require_read
|
from app.vsphere.security.authz import require_read
|
||||||
from app.vsphere.security.session import SessionInfo
|
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")
|
vm = params.get("vm")
|
||||||
if vm and "/hardware/" in template:
|
if vm and "/hardware/" in template:
|
||||||
obj = await inventory.get_object(database, vm)
|
obj = await inventory.require_object(database, vm, type_name="VirtualMachine", resource="vm")
|
||||||
if obj is None:
|
|
||||||
raise not_found(f"VM {vm} not found")
|
|
||||||
props = obj.props or {}
|
props = obj.props or {}
|
||||||
|
|
||||||
def _live_list(key: str) -> list[Any] | None:
|
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
|
return nic
|
||||||
|
|
||||||
if vm and "/guest/" in template:
|
if vm and "/guest/" in template:
|
||||||
obj = await inventory.get_object(database, vm)
|
obj = await inventory.require_object(database, vm, type_name="VirtualMachine", resource="vm")
|
||||||
if obj is None:
|
|
||||||
raise not_found(f"VM {vm} not found")
|
|
||||||
props = obj.props or {}
|
props = obj.props or {}
|
||||||
if template.endswith("/guest/local-filesystem"):
|
if template.endswith("/guest/local-filesystem"):
|
||||||
if "guest_filesystems" in props:
|
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")
|
host = params.get("host")
|
||||||
if host and template.endswith("/networking"):
|
if host and template.endswith("/networking"):
|
||||||
obj = await inventory.get_object(database, host)
|
obj = await inventory.require_object(database, host, type_name="HostSystem", resource="host")
|
||||||
if obj is None:
|
|
||||||
raise not_found(f"Host {host} not found")
|
|
||||||
networking = (obj.props or {}).get("networking")
|
networking = (obj.props or {}).get("networking")
|
||||||
return networking if networking is not None else None
|
return networking if networking is not None else None
|
||||||
if host and "storage-device" in template:
|
if host and "storage-device" in template:
|
||||||
obj = await inventory.get_object(database, host)
|
obj = await inventory.require_object(database, host, type_name="HostSystem", resource="host")
|
||||||
if obj is None:
|
|
||||||
raise not_found(f"Host {host} not found")
|
|
||||||
devices = (obj.props or {}).get("storage_devices")
|
devices = (obj.props or {}).get("storage_devices")
|
||||||
return devices if devices is not None else None
|
return devices if devices is not None else None
|
||||||
|
|
||||||
@@ -156,9 +147,7 @@ async def _mutate_vm_hardware(
|
|||||||
vm = params.get("vm")
|
vm = params.get("vm")
|
||||||
if not vm or "/hardware/" not in template:
|
if not vm or "/hardware/" not in template:
|
||||||
return False
|
return False
|
||||||
obj = await inventory.get_object(database, vm)
|
obj = await inventory.require_object(database, vm, type_name="VirtualMachine", resource="vm")
|
||||||
if obj is None:
|
|
||||||
raise not_found(f"VM {vm} not found")
|
|
||||||
props = dict(obj.props or {})
|
props = dict(obj.props or {})
|
||||||
|
|
||||||
if verb == "POST" and template.endswith("/hardware/cdrom"):
|
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() {
|
async function logout() {
|
||||||
try {
|
try {
|
||||||
if (state.ticket) {
|
if (state.ticket) {
|
||||||
@@ -6240,15 +6255,15 @@
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(error);
|
console.warn(error);
|
||||||
}
|
}
|
||||||
state.ticket = null;
|
clearSession({ toastMessage: "Signed out", toastKind: "info" });
|
||||||
state.csrf = null;
|
}
|
||||||
state.username = null;
|
|
||||||
document.cookie = "vmware-api-session-id=; Max-Age=0; path=/; SameSite=Strict";
|
function expireSession() {
|
||||||
setAuth(false);
|
clearSession({
|
||||||
persistAuth();
|
toastMessage: "Session expired — sign in again",
|
||||||
closeAuthPanel();
|
toastKind: "warn",
|
||||||
resetClusterStats("—");
|
closePanel: false,
|
||||||
toast("Signed out", "info");
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateStatusBadge(status) {
|
function updateStatusBadge(status) {
|
||||||
@@ -6340,6 +6355,9 @@
|
|||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
let parsed;
|
let parsed;
|
||||||
try { parsed = JSON.parse(text); } catch { parsed = text; }
|
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) };
|
return { status: res.status, body: parsed, durationMs: Math.round(performance.now() - started) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,60 @@ DB-backed стаб — 501 не должен появляться для изв
|
|||||||
base64 от `user:password`).
|
base64 от `user:password`).
|
||||||
- Отказ по правам — попробуйте сравнить `administrator@vsphere.local` и
|
- Отказ по правам — попробуйте сравнить `administrator@vsphere.local` и
|
||||||
`readonly@vsphere.local` (см. [Авторизация](domains/authz.md)).
|
`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"}}'
|
||||||
|
```
|
||||||
|
|
||||||
## Задача никогда не завершается
|
## Задача никогда не завершается
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,59 @@ should not appear for a known path. If you see it:
|
|||||||
`user:password` base64).
|
`user:password` base64).
|
||||||
- Privilege denial — try `administrator@vsphere.local` vs
|
- Privilege denial — try `administrator@vsphere.local` vs
|
||||||
`readonly@vsphere.local` to compare (see [Authorization](domains/authz.md)).
|
`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
|
## Task never finishes
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ seed:
|
|||||||
ingress:
|
ingress:
|
||||||
enabled: true
|
enabled: true
|
||||||
className: nginx
|
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:
|
hosts:
|
||||||
- host: vmware-sim.example.com
|
- host: vmware-sim.example.com
|
||||||
paths:
|
paths:
|
||||||
|
|||||||
@@ -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"]
|
||||||
Reference in New Issue
Block a user