Add OpenStack request-body schemas and nested console PARAM sync.

This commit is contained in:
2026-07-18 08:47:16 +03:00
parent 6033967e6a
commit 2a8ce3a287
101 changed files with 150428 additions and 612 deletions
+3 -2
View File
@@ -244,12 +244,13 @@ async def download_image_file(
size = int((data or {}).get("size") or 0)
else:
size = int(row["size"] or 0)
# Always return at least one byte so clients / coverage see a real payload.
# Lab payload is capped; Content-Length must match the bytes we actually send
# (advertising the virtual image size breaks urllib/clients with IncompleteRead).
content = b"\0" * min(size, 64) if size else b"probe-image"
return Response(
content=content,
media_type="application/octet-stream",
headers={"Content-Length": str(len(content) if not size else size)},
headers={"Content-Length": str(len(content))},
)
+71 -15
View File
@@ -146,6 +146,52 @@ async def show_stack_by_id(
return {"stack": _stack(row)}
async def _update_stack(
*,
project_id: Any,
stack_id: str | None,
stack_name: str | None,
request: Request,
conn: Connection,
) -> dict[str, object]:
payload = await request.json()
stack = payload.get("stack") or payload
if stack_id:
row = await conn.fetchrow(
"SELECT * FROM os_stacks WHERE project_id=$1 AND id::text=$2",
project_id,
stack_id,
)
else:
row = await conn.fetchrow(
"""SELECT * FROM os_stacks
WHERE project_id=$1 AND (id::text=$2 OR stack_name=$2)
ORDER BY created_at DESC LIMIT 1""",
project_id,
stack_name,
)
if row is None:
raise OpenStackError("StackNotFound", "Stack not found", status_code=404)
desc = stack.get("description") if "description" in stack else row["description"]
template = stack.get("template") if isinstance(stack.get("template"), dict) else None
parameters = stack.get("parameters") if isinstance(stack.get("parameters"), dict) else None
await conn.execute(
"""UPDATE os_stacks
SET description=$1,
template=COALESCE($2::jsonb, template),
parameters=COALESCE($3::jsonb, parameters),
updated_at=now(),
stack_status='UPDATE_COMPLETE'
WHERE id=$4""",
desc,
json.dumps(template) if template is not None else None,
json.dumps(parameters) if parameters is not None else None,
row["id"],
)
row = await conn.fetchrow("SELECT * FROM os_stacks WHERE id=$1", row["id"])
return {"stack": _stack(row)}
@router.put("/v1/{tenant_id}/stacks/{id}")
@router.patch("/v1/{tenant_id}/stacks/{id}")
async def update_stack_by_id(
@@ -156,23 +202,33 @@ async def update_stack_by_id(
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
_ = tenant_id
payload = await request.json()
stack = payload.get("stack") or payload
row = await conn.fetchrow(
"SELECT * FROM os_stacks WHERE project_id=$1 AND (id::text=$2 OR stack_name=$2) ORDER BY created_at DESC LIMIT 1",
ctx.project_id,
id,
return await _update_stack(
project_id=ctx.project_id,
stack_id=None,
stack_name=id,
request=request,
conn=conn,
)
if row is None:
raise OpenStackError("StackNotFound", "Stack not found", status_code=404)
desc = stack.get("description") if "description" in stack else row["description"]
await conn.execute(
"UPDATE os_stacks SET description=$1, updated_at=now(), stack_status='UPDATE_COMPLETE' WHERE id=$2",
desc,
row["id"],
@router.put("/v1/{tenant_id}/stacks/{stack_name}/{stack_id}")
@router.patch("/v1/{tenant_id}/stacks/{stack_name}/{stack_id}")
async def update_stack_by_name(
tenant_id: str,
stack_name: str,
stack_id: str,
request: Request,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
_ = tenant_id, stack_name
return await _update_stack(
project_id=ctx.project_id,
stack_id=stack_id,
stack_name=None,
request=request,
conn=conn,
)
row = await conn.fetchrow("SELECT * FROM os_stacks WHERE id=$1", row["id"])
return {"stack": _stack(row)}
@router.delete("/v1/{tenant_id}/stacks/{id}", status_code=204)
+7 -4
View File
@@ -17,17 +17,20 @@ router = APIRouter(tags=["Neutron"])
def _net(row: Any) -> dict[str, Any]:
# Lab convention: shared network named "public" is the external provider net.
name = str(row["name"] or "")
is_external = bool(row["shared"]) and name == "public"
return {
"id": str(row["id"]),
"name": row["name"],
"name": name,
"status": row["status"],
"shared": row["shared"],
"admin_state_up": row["admin_state_up"],
"tenant_id": str(row["project_id"]),
"project_id": str(row["project_id"]),
"router:external": False,
"provider:network_type": "vxlan",
"mtu": 1450,
"router:external": is_external,
"provider:network_type": "flat" if is_external else "vxlan",
"mtu": 1500 if is_external else 1450,
}
+72 -21
View File
@@ -409,8 +409,8 @@ async def server_action(
"unrescue": "ACTIVE",
"os-stop": "SHUTOFF",
"osStop": "SHUTOFF",
"shelve": "SHUTOFF",
"shelveOffload": "SHUTOFF",
"shelve": "SHELVED",
"shelveOffload": "SHELVED_OFFLOADED",
"pause": "PAUSED",
"suspend": "SUSPENDED",
"rescue": "RESCUE",
@@ -905,6 +905,18 @@ async def availability_zones(
}
def _aggregate_dict(row: Any) -> dict[str, object]:
hosts = row["hosts"]
metadata = row["metadata"]
return {
"id": row["id"],
"name": row["name"],
"availability_zone": row["availability_zone"],
"hosts": hosts if not isinstance(hosts, str) else json.loads(hosts),
"metadata": metadata if not isinstance(metadata, str) else json.loads(metadata),
}
@router.get("/v2.1/os-aggregates")
async def list_aggregates(
conn: Annotated[Connection, Depends(get_conn)],
@@ -914,20 +926,24 @@ async def list_aggregates(
rows = await conn.fetch("SELECT * FROM os_aggregates ORDER BY id")
except Exception:
rows = []
return {
"aggregates": [
{
"id": r["id"],
"name": r["name"],
"availability_zone": r["availability_zone"],
"hosts": r["hosts"] if not isinstance(r["hosts"], str) else json.loads(r["hosts"]),
"metadata": r["metadata"]
if not isinstance(r["metadata"], str)
else json.loads(r["metadata"]),
}
for r in rows
]
}
return {"aggregates": [_aggregate_dict(r) for r in rows]}
@router.get("/v2.1/os-aggregates/{aggregate_id}")
async def show_aggregate(
aggregate_id: str,
conn: Annotated[Connection, Depends(get_conn)],
_ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
row = await conn.fetchrow(
"""SELECT * FROM os_aggregates
WHERE id::text=$1 OR name=$1
LIMIT 1""",
aggregate_id,
)
if row is None:
raise OpenStackError("NotFound", f"aggregate {aggregate_id} not found", status_code=404)
return {"aggregate": _aggregate_dict(row)}
@router.get("/v2.1/os-services")
@@ -1137,7 +1153,8 @@ async def instance_actions(
) -> dict[str, object]:
rows = await conn.fetch(
"""SELECT id, name, data FROM os_api_objects
WHERE service='nova' AND resource_type='instance_action' AND project_id=$1
WHERE service='nova' AND resource_type='instance_action'
AND (project_id=$1 OR project_id IS NULL)
AND (data->>'server_id'=$2 OR data->>'instance_uuid'=$2)
ORDER BY created_at DESC
LIMIT 20""",
@@ -1170,7 +1187,8 @@ async def show_instance_action(
) -> dict[str, object]:
row = await conn.fetchrow(
"""SELECT id, name, data FROM os_api_objects
WHERE service='nova' AND resource_type='instance_action' AND project_id=$1
WHERE service='nova' AND resource_type='instance_action'
AND (project_id=$1 OR project_id IS NULL)
AND (id::text=$2 OR data->>'request_id'=$2 OR name=$2)
ORDER BY created_at DESC
LIMIT 1""",
@@ -1181,7 +1199,8 @@ async def show_instance_action(
# Prefer an existing action for this server; otherwise persist the requested id.
row = await conn.fetchrow(
"""SELECT id, name, data FROM os_api_objects
WHERE service='nova' AND resource_type='instance_action' AND project_id=$1
WHERE service='nova' AND resource_type='instance_action'
AND (project_id=$1 OR project_id IS NULL)
AND (data->>'server_id'=$2 OR data->>'instance_uuid'=$2)
ORDER BY created_at DESC LIMIT 1""",
ctx.project_id,
@@ -1243,7 +1262,8 @@ async def _load_server_metadata(
return metadata, public
api = await conn.fetchrow(
"""SELECT data FROM os_api_objects
WHERE service='nova' AND resource_type='server_metadata' AND project_id=$1
WHERE service='nova' AND resource_type='server_metadata'
AND (project_id=$1 OR project_id IS NULL)
AND (data->>'server_id'=$2 OR id::text=$2)
ORDER BY created_at LIMIT 1""",
project_id,
@@ -1557,6 +1577,36 @@ async def server_security_groups(
}
@router.get("/v2.1/servers/{server_id}/os-security-groups/{security_group_id}")
async def show_server_security_group(
server_id: str,
security_group_id: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]:
_ = server_id
row = await conn.fetchrow(
"""SELECT * FROM os_security_groups
WHERE project_id=$1 AND (id::text=$2 OR name=$2)
LIMIT 1""",
ctx.project_id,
security_group_id,
)
if row is None:
raise OpenStackError(
"NotFound",
f"security_group {security_group_id} not found",
status_code=404,
)
return {
"security_group": {
"id": str(row["id"]),
"name": row["name"],
"description": row["description"],
}
}
@router.get("/v2.1/servers/{server_id}/topology")
async def server_topology(
server_id: str,
@@ -1648,7 +1698,8 @@ async def volume_attachments(
) -> dict[str, object]:
rows = await conn.fetch(
"""SELECT * FROM os_api_objects
WHERE service='nova' AND resource_type='volume_attachment' AND project_id=$1
WHERE service='nova' AND resource_type='volume_attachment'
AND (project_id=$1 OR project_id IS NULL)
AND (data->>'server_id'=$2 OR data->>'serverId'=$2)
ORDER BY created_at""",
ctx.project_id,
+30 -1
View File
@@ -8,7 +8,6 @@ from uuid import uuid4
from asyncpg import Connection
from fastapi import APIRouter, Depends, Request, Response
from fastapi.responses import PlainTextResponse
from app.openstack.auth import TokenContext
from app.openstack.deps import get_conn, require_token
@@ -72,6 +71,36 @@ async def create_container(
return Response(status_code=201)
@router.delete("/v1/{account}/{container}", status_code=204)
async def delete_container(
account: str,
container: str,
conn: Annotated[Connection, Depends(get_conn)],
ctx: Annotated[TokenContext, Depends(require_token)],
) -> Response:
_ = account
acct = _account(ctx)
objects = await conn.fetchval(
"SELECT count(*) FROM os_swift_objects WHERE account=$1 AND container=$2",
acct,
container,
)
if int(objects or 0) > 0:
raise OpenStackError(
"Conflict",
"Container is not empty",
status_code=409,
)
result = await conn.execute(
"DELETE FROM os_swift_containers WHERE account=$1 AND name=$2",
acct,
container,
)
if result.endswith("0"):
raise OpenStackError("NotFound", "Container not found", status_code=404)
return Response(status_code=204)
@router.get("/v1/{account}/{container}")
async def list_objects(
account: str,