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.
This commit is contained in:
+15
-3
@@ -76,7 +76,11 @@ async def require_node(request: Request, node: str) -> None:
|
|||||||
node,
|
node,
|
||||||
)
|
)
|
||||||
if not exists:
|
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]:
|
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,
|
node,
|
||||||
)
|
)
|
||||||
if row is None:
|
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"])
|
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),
|
json.dumps(metadata, sort_keys=True),
|
||||||
)
|
)
|
||||||
if status != "UPDATE 1":
|
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]:
|
def storage_payload(row: Any) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -125,7 +125,11 @@ async def _show_server(
|
|||||||
ctx.project_id,
|
ctx.project_id,
|
||||||
)
|
)
|
||||||
if row is None:
|
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)}
|
return {"server": _server_dict(row)}
|
||||||
|
|
||||||
|
|
||||||
@@ -164,7 +168,11 @@ async def _update_server(
|
|||||||
ctx.project_id,
|
ctx.project_id,
|
||||||
)
|
)
|
||||||
if row is None:
|
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)}
|
return {"server": _server_dict(row)}
|
||||||
|
|
||||||
|
|
||||||
@@ -299,7 +307,11 @@ async def delete_server(
|
|||||||
ctx.project_id,
|
ctx.project_id,
|
||||||
)
|
)
|
||||||
if result.endswith("0"):
|
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)
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
@@ -316,7 +328,11 @@ async def server_action(
|
|||||||
ctx.project_id,
|
ctx.project_id,
|
||||||
)
|
)
|
||||||
if row is None:
|
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
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
action = await request.json()
|
action = await request.json()
|
||||||
|
|||||||
@@ -438,7 +438,7 @@ async def _handle_show(
|
|||||||
item_id,
|
item_id,
|
||||||
)
|
)
|
||||||
if row is None:
|
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)
|
return JSONResponse(_fixture_or_item(op, _row_item(row)), status_code=op.status_code)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+32
-15
@@ -4982,12 +4982,7 @@
|
|||||||
if (!res.ok) throw new Error("session expired");
|
if (!res.ok) throw new Error("session expired");
|
||||||
setAuth(true);
|
setAuth(true);
|
||||||
} catch {
|
} catch {
|
||||||
state.ticket = null;
|
clearSession({ closePanel: false });
|
||||||
state.csrf = null;
|
|
||||||
state.username = null;
|
|
||||||
state.project = null;
|
|
||||||
localStorage.removeItem(LS_AUTH);
|
|
||||||
setAuth(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() {
|
function logout() {
|
||||||
const token = state.ticket;
|
const token = state.ticket;
|
||||||
if (token) {
|
if (token) {
|
||||||
@@ -6350,15 +6364,15 @@
|
|||||||
headers: { "X-Auth-Token": token, "X-Subject-Token": token },
|
headers: { "X-Auth-Token": token, "X-Subject-Token": token },
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
state.ticket = null;
|
clearSession({ toastMessage: "Signed out", toastKind: "info" });
|
||||||
state.csrf = null;
|
}
|
||||||
state.username = null;
|
|
||||||
state.project = null;
|
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) {
|
||||||
@@ -6472,6 +6486,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) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ What this does:
|
|||||||
5. Deploys nginx **api-gateway** with OpenStack default ports (5000, 8774, 9696, …).
|
5. Deploys nginx **api-gateway** with OpenStack default ports (5000, 8774, 9696, …).
|
||||||
6. Creates `ClusterIssuer` resources (`letsencrypt-prod` / `letsencrypt-staging`).
|
6. Creates `ClusterIssuer` resources (`letsencrypt-prod` / `letsencrypt-staging`).
|
||||||
7. Creates an Ingress → gateway `:5000` (Keystone + Web UI) with TLS.
|
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:
|
DNS for `os-sim.example.com` must point at your Ingress controller. Then:
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ helm upgrade --install os-sim ./helm/openstack-api-simulator \
|
|||||||
5. Разворачивается nginx **api-gateway** со стандартными портами OpenStack (5000, 8774, 9696, …).
|
5. Разворачивается nginx **api-gateway** со стандартными портами OpenStack (5000, 8774, 9696, …).
|
||||||
6. Создаются ресурсы `ClusterIssuer` (`letsencrypt-prod` / `letsencrypt-staging`).
|
6. Создаются ресурсы `ClusterIssuer` (`letsencrypt-prod` / `letsencrypt-staging`).
|
||||||
7. Создаётся Ingress → gateway `:5000` (Keystone + Web UI) с TLS.
|
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. Затем:
|
DNS для `os-sim.example.com` должен указывать на Ingress controller. Затем:
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,50 @@
|
|||||||
- Неверный user/password/domain (`Default`)
|
- Неверный user/password/domain (`Default`)
|
||||||
- Отсутствует project scope для project-scoped API
|
- Отсутствует project scope для project-scoped API
|
||||||
- Токен от другого экземпляра simulator (reseed меняет ID)
|
- Токен от другого экземпляра 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
|
## Пустые списки после lifecycle probe
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,50 @@
|
|||||||
- Wrong user/password/domain (`Default`)
|
- Wrong user/password/domain (`Default`)
|
||||||
- Project scope missing for project-scoped APIs
|
- Project scope missing for project-scoped APIs
|
||||||
- Token from a different simulator instance (reseed rotates IDs)
|
- 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
|
## Empty lists after lifecycle probe
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,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: os-sim.example.com
|
- host: os-sim.example.com
|
||||||
paths:
|
paths:
|
||||||
|
|||||||
@@ -176,5 +176,5 @@ async def test_missing_node_returns_404() -> None:
|
|||||||
pool.node_exists = False
|
pool.node_exists = False
|
||||||
handler = registry.get("/nodes/{node}/storage", "GET")
|
handler = registry.get("/nodes/{node}/storage", "GET")
|
||||||
assert handler is not None
|
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"}})
|
await handler(_request(pool), {"values": {"node": "missing"}})
|
||||||
|
|||||||
Reference in New Issue
Block a user