From f1289858fde2bf6de740d745865611498fff137f Mon Sep 17 00:00:00 2001 From: Sergey Antropoff Date: Wed, 22 Jul 2026 06:52:23 +0300 Subject: [PATCH] Clear Web UI session on 401 and keep API/Ingress errors as JSON. Include the missing resource id in not-found messages, expire auth to Guest after 401, and document Ingress annotations so nginx does not rewrite 404/405 bodies into branded HTML. --- app/handlers/common.py | 18 +++++-- app/openstack/routes/nova.py | 24 ++++++++-- app/openstack/schema_engine.py | 2 +- app/web/index.html | 47 +++++++++++++------ docs/kubernetes.md | 4 ++ docs/ru/kubernetes.md | 4 ++ docs/ru/troubleshooting.md | 44 +++++++++++++++++ docs/troubleshooting.md | 44 +++++++++++++++++ .../values-ingress-example.yaml | 7 +++ tests/unit/test_extended_handlers.py | 2 +- 10 files changed, 172 insertions(+), 24 deletions(-) diff --git a/app/handlers/common.py b/app/handlers/common.py index 31f8f0c..b0d4a23 100644 --- a/app/handlers/common.py +++ b/app/handlers/common.py @@ -76,7 +76,11 @@ async def require_node(request: Request, node: str) -> None: node, ) if not exists: - raise ApiError(404, "node does not exist") + raise ApiError( + 404, + f"No such node ('{node}')", + errors={"node": f"No such node ('{node}')"}, + ) async def cluster_metadata(request: Request) -> dict[str, Any]: @@ -105,7 +109,11 @@ async def node_metadata(request: Request, node: str) -> dict[str, Any]: node, ) if row is None: - raise ApiError(404, "node does not exist") + raise ApiError( + 404, + f"No such node ('{node}')", + errors={"node": f"No such node ('{node}')"}, + ) return state(row["metadata"]) @@ -116,7 +124,11 @@ async def save_node_metadata(request: Request, node: str, metadata: dict[str, An json.dumps(metadata, sort_keys=True), ) if status != "UPDATE 1": - raise ApiError(404, "node does not exist") + raise ApiError( + 404, + f"No such node ('{node}')", + errors={"node": f"No such node ('{node}')"}, + ) def storage_payload(row: Any) -> dict[str, Any]: diff --git a/app/openstack/routes/nova.py b/app/openstack/routes/nova.py index 4cba281..a7d24f5 100644 --- a/app/openstack/routes/nova.py +++ b/app/openstack/routes/nova.py @@ -125,7 +125,11 @@ async def _show_server( ctx.project_id, ) if row is None: - raise OpenStackError("computeFault", "Instance could not be found", status_code=404) + raise OpenStackError( + "computeFault", + f"Instance '{resource_id}' could not be found", + status_code=404, + ) return {"server": _server_dict(row)} @@ -164,7 +168,11 @@ async def _update_server( ctx.project_id, ) if row is None: - raise OpenStackError("computeFault", "Instance could not be found", status_code=404) + raise OpenStackError( + "computeFault", + f"Instance '{resource_id}' could not be found", + status_code=404, + ) return {"server": _server_dict(row)} @@ -299,7 +307,11 @@ async def delete_server( ctx.project_id, ) if result.endswith("0"): - raise OpenStackError("computeFault", "Instance could not be found", status_code=404) + raise OpenStackError( + "computeFault", + f"Instance '{server_id}' could not be found", + status_code=404, + ) return Response(status_code=204) @@ -316,7 +328,11 @@ async def server_action( ctx.project_id, ) if row is None: - raise OpenStackError("computeFault", "Instance could not be found", status_code=404) + raise OpenStackError( + "computeFault", + f"Instance '{server_id}' could not be found", + status_code=404, + ) from fastapi.responses import JSONResponse action = await request.json() diff --git a/app/openstack/schema_engine.py b/app/openstack/schema_engine.py index bd0e9d0..ba6783c 100644 --- a/app/openstack/schema_engine.py +++ b/app/openstack/schema_engine.py @@ -438,7 +438,7 @@ async def _handle_show( item_id, ) if row is None: - raise OpenStackError("NotFound", f"{op.resource_type} {item_id} not found", status_code=404) + raise OpenStackError("NotFound", f"{op.resource_type} '{item_id}' not found", status_code=404) return JSONResponse(_fixture_or_item(op, _row_item(row)), status_code=op.status_code) diff --git a/app/web/index.html b/app/web/index.html index fcc9c2e..61c9d21 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -4982,12 +4982,7 @@ if (!res.ok) throw new Error("session expired"); setAuth(true); } catch { - state.ticket = null; - state.csrf = null; - state.username = null; - state.project = null; - localStorage.removeItem(LS_AUTH); - setAuth(false); + clearSession({ closePanel: false }); } } @@ -6342,6 +6337,25 @@ } } + function clearSession(options = {}) { + const { toastMessage = null, toastKind = "info", closePanel = true } = options; + state.ticket = null; + state.csrf = null; + state.username = null; + state.project = null; + try { + localStorage.removeItem(LS_AUTH); + sessionStorage.removeItem(LS_AUTH); + } catch { + /* ignore storage failures */ + } + setAuth(false); + persistAuth(); + if (closePanel) closeAuthPanel(); + resetClusterStats("—"); + if (toastMessage) toast(toastMessage, toastKind); + } + function logout() { const token = state.ticket; if (token) { @@ -6350,15 +6364,15 @@ headers: { "X-Auth-Token": token, "X-Subject-Token": token }, }).catch(() => {}); } - state.ticket = null; - state.csrf = null; - state.username = null; - state.project = null; - 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) { @@ -6472,6 +6486,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/kubernetes.md b/docs/kubernetes.md index f8051e3..c37511c 100644 --- a/docs/kubernetes.md +++ b/docs/kubernetes.md @@ -54,6 +54,10 @@ What this does: 5. Deploys nginx **api-gateway** with OpenStack default ports (5000, 8774, 9696, …). 6. Creates `ClusterIssuer` resources (`letsencrypt-prod` / `letsencrypt-staging`). 7. Creates an Ingress → gateway `:5000` (Keystone + Web UI) with TLS. +8. Sets Ingress annotations so nginx does not replace API JSON 404/405 with + branded HTML error pages (`proxy-intercept-errors: false`, narrow + `custom-http-errors`). See + [Troubleshooting](troubleshooting.md#ingress-returns-branded-html-404--nginx-405-instead-of-json). DNS for `os-sim.example.com` must point at your Ingress controller. Then: diff --git a/docs/ru/kubernetes.md b/docs/ru/kubernetes.md index efdd6e7..aaecc66 100644 --- a/docs/ru/kubernetes.md +++ b/docs/ru/kubernetes.md @@ -54,6 +54,10 @@ helm upgrade --install os-sim ./helm/openstack-api-simulator \ 5. Разворачивается nginx **api-gateway** со стандартными портами OpenStack (5000, 8774, 9696, …). 6. Создаются ресурсы `ClusterIssuer` (`letsencrypt-prod` / `letsencrypt-staging`). 7. Создаётся Ingress → gateway `:5000` (Keystone + Web UI) с TLS. +8. Ставит annotations Ingress, чтобы nginx не подменял JSON 404/405 + брендированными HTML-страницами (`proxy-intercept-errors: false`, узкий + `custom-http-errors`). См. + [Устранение неполадок](troubleshooting.md#ingress-отдаёт-брендированный-html-404--nginx-405-вместо-json). DNS для `os-sim.example.com` должен указывать на Ingress controller. Затем: diff --git a/docs/ru/troubleshooting.md b/docs/ru/troubleshooting.md index 52c0bfd..ed40931 100644 --- a/docs/ru/troubleshooting.md +++ b/docs/ru/troubleshooting.md @@ -13,6 +13,50 @@ - Неверный user/password/domain (`Default`) - Отсутствует project scope для project-scoped API - Токен от другого экземпляра simulator (reseed меняет ID) +- В Web UI HTTP 401 очищает локальную сессию Keystone и показывает **Guest** + в шапке; войдите снова через Environment + +## Ingress отдаёт брендированный HTML 404 / nginx 405 вместо JSON + +Симулятор отвечает на ошибки API JSON (`error` / `itemNotFound` / `message`). +Если видите HTML «page not found» или голую страницу nginx **405**, тело +подменил **Ingress / reverse proxy** (часто `custom-http-errors` у +ingress-nginx). + +Исправьте annotations Ingress для этого хоста (см. +`helm/openstack-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`. Отсутствующий compute instance должен +вернуться JSON (не HTML), например: + +```json +{"itemNotFound": {"code": 404, "message": "Instance 'missing-id' could not be found"}} +``` + +### Корректная authenticated mutation (OpenStack) + +Токен Keystone в заголовке и JSON-тело (OpenStack API — JSON, не +form-urlencoded): + +```bash +# после POST /v3/auth/tokens → X-Subject-Token +TOKEN=... +curl -sS -X POST "https://HOST:8774/v2.1/servers" \ + -H "X-Auth-Token: $TOKEN" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d '{"server":{"name":"demo","flavorRef":"...","imageRef":"...","networks":[{"uuid":"..."}]}}' +``` + +Keystone/UI через Ingress обычно `:443→5000`; порты Nova и других сервисов +по-прежнему нуждаются в port-forward / LoadBalancer / TCP Ingress, если вы не +ходите через multi-port gateway Service. ## Пустые списки после lifecycle probe diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 87a4235..9aadb34 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -13,6 +13,50 @@ - Wrong user/password/domain (`Default`) - Project scope missing for project-scoped APIs - Token from a different simulator instance (reseed rotates IDs) +- In the Web UI, HTTP 401 clears the local Keystone session and shows **Guest** + in the header; sign in again from Environment + +## Ingress returns branded HTML 404 / nginx 405 instead of JSON + +The simulator answers API errors as JSON (`error` / `itemNotFound` / `message`). +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/openstack-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 compute instance should +look like JSON (not HTML), for example: + +```json +{"itemNotFound": {"code": 404, "message": "Instance 'missing-id' could not be found"}} +``` + +### Correct authenticated mutation (OpenStack) + +Use a Keystone token header and JSON body (OpenStack APIs are JSON, not +form-urlencoded): + +```bash +# after POST /v3/auth/tokens → X-Subject-Token +TOKEN=... +curl -sS -X POST "https://HOST:8774/v2.1/servers" \ + -H "X-Auth-Token: $TOKEN" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d '{"server":{"name":"demo","flavorRef":"...","imageRef":"...","networks":[{"uuid":"..."}]}}' +``` + +Keystone/UI via Ingress is usually `:443→5000`; Nova and other service ports +still need port-forward / LoadBalancer / TCP Ingress unless you only call +through the multi-port gateway Service. ## Empty lists after lifecycle probe diff --git a/helm/openstack-api-simulator/values-ingress-example.yaml b/helm/openstack-api-simulator/values-ingress-example.yaml index 39117bf..b864fb3 100644 --- a/helm/openstack-api-simulator/values-ingress-example.yaml +++ b/helm/openstack-api-simulator/values-ingress-example.yaml @@ -35,6 +35,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: os-sim.example.com paths: diff --git a/tests/unit/test_extended_handlers.py b/tests/unit/test_extended_handlers.py index de70877..7e3f89d 100644 --- a/tests/unit/test_extended_handlers.py +++ b/tests/unit/test_extended_handlers.py @@ -176,5 +176,5 @@ async def test_missing_node_returns_404() -> None: pool.node_exists = False handler = registry.get("/nodes/{node}/storage", "GET") assert handler is not None - with pytest.raises(ApiError, match="node does not exist"): + with pytest.raises(ApiError, match=r"No such node \('missing'\)"): await handler(_request(pool), {"values": {"node": "missing"}})