Add OpenStack request-body schemas and nested console PARAM sync.
This commit is contained in:
@@ -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))},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user