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
+18
View File
@@ -9,6 +9,7 @@ from pathlib import Path
from typing import Any
from app.openstack.opspec import OperationSpec, SeriesManifest, ServicePack
from app.openstack.request_bodies import attach_request_schemas, clear_request_body_cache
_CONTRACTS_ROOT = Path(__file__).resolve().parents[2] / "contracts" / "openstack"
@@ -43,6 +44,17 @@ def list_series() -> list[dict[str, Any]]:
if not man.is_file():
continue
data = json.loads(man.read_text())
microversions = [
{
"name": str(svc.get("name") or ""),
"default_microversion": svc.get("default_microversion"),
"max_microversion": svc.get("max_microversion"),
}
for svc in data.get("services") or []
if isinstance(svc, dict)
and svc.get("name")
and (svc.get("default_microversion") or svc.get("max_microversion"))
]
result.append(
{
"series": data.get("series", path.name),
@@ -51,6 +63,7 @@ def list_series() -> list[dict[str, Any]]:
"service_count": data.get("service_count", 0),
"checksum": data.get("checksum", ""),
"generated_at": data.get("generated_at", ""),
"microversions": microversions,
}
)
return result
@@ -92,6 +105,7 @@ def load_series_pack(series: str) -> dict[str, ServicePack]:
data = json.loads(api.read_text())
name = str(data["service"])
ops = [_op_from_dict(name, raw) for raw in data.get("operations") or []]
ops = attach_request_schemas(name, ops)
packs[name] = ServicePack(
name=name,
typ=str(data.get("type") or name),
@@ -129,9 +143,13 @@ class ContractRuntime:
def reload(self, series: str | None = None) -> dict[str, Any]:
with self._lock:
clear_request_body_cache()
target = (series or self.series).lower()
series_changed = target != self.series
self.packs = load_series_pack(target)
self.series = target
if series_changed:
self.microversion_overrides.clear()
man = load_manifest(target)
return {
"series": man.series,
+279 -75
View File
@@ -1,8 +1,9 @@
"""Enterprise-scale OpenStack demo cloud seed (~1000 servers + full topology)."""
"""Sized OpenStack demo cloud seed (small / large / big synthetic clusters)."""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass
from typing import Any
from uuid import UUID
@@ -12,14 +13,108 @@ from app.openstack.ids import oid
from app.security.auth import hash_secret
DEMO_PROFILE = "openstack-demo-cloud"
DEMO_SIZE_DEFAULT = "large"
DEMO_SERVER_COUNT = 1000
DEMO_VOLUME_COUNT = 600
DEMO_HYPERVISOR_COUNT = 16
DEMO_IRONIC_COUNT = 24
DEMO_LB_COUNT = 12
DEMO_STACK_COUNT = 30
DEMO_FIP_COUNT = 120
@dataclass(frozen=True, slots=True)
class DemoClusterSize:
"""Proportional inventory for a demo cluster size button."""
name: str
hypervisors: int
servers: int
volumes: int
ironic_nodes: int
loadbalancers: int
stacks: int
floating_ips: int
surface_samples: int
server_groups: int
nested_samples: int
pack_per_type: int
extra_networks: int
extra_security_groups: int
keypairs_per_user: int
edge_routers: int
def _scale(base: int, factor: float, minimum: int) -> int:
return max(minimum, int(round(base * factor)))
def _cluster(name: str, *, hypervisors: int, servers: int) -> DemoClusterSize:
"""Derive secondary counts from the large (1000 VM) reference ratios."""
factor = servers / 1000
return DemoClusterSize(
name=name,
hypervisors=hypervisors,
servers=servers,
volumes=_scale(600, factor, 20),
ironic_nodes=_scale(24, factor, 2),
loadbalancers=_scale(12, factor, 2),
stacks=_scale(30, factor, 3),
floating_ips=_scale(120, factor, 6),
surface_samples=_scale(8, factor, 3),
server_groups=_scale(16, factor, 3),
nested_samples=_scale(24, factor, 8),
pack_per_type=_scale(3, factor, 2),
# Topology density (large reference: 3 extra nets, 3 SG tiers, 4 keys, 1 edge router).
extra_networks=_scale(3, factor, 1),
extra_security_groups=_scale(3, factor, 1),
keypairs_per_user=_scale(4, factor, 2),
edge_routers=_scale(1, factor, 0),
)
DEMO_CLUSTER_SIZES: dict[str, DemoClusterSize] = {
"small": _cluster("small", hypervisors=3, servers=50),
"large": _cluster("large", hypervisors=10, servers=1000),
"big": _cluster("big", hypervisors=20, servers=2000),
}
def resolve_demo_size(size: str | None) -> DemoClusterSize:
key = (size or DEMO_SIZE_DEFAULT).strip().lower()
aliases = {
"demo": "large",
"demo-cloud": "large",
"openstack-demo-cloud": "large",
"demo-small": "small",
"demo-large": "large",
"demo-big": "big",
"medium": "large",
}
key = aliases.get(key, key)
if key not in DEMO_CLUSTER_SIZES:
known = ", ".join(sorted(DEMO_CLUSTER_SIZES))
raise ValueError(f"unknown demo size {size!r} (use {known})")
return DEMO_CLUSTER_SIZES[key]
def demo_profile_name(size: str | DemoClusterSize) -> str:
name = size.name if isinstance(size, DemoClusterSize) else resolve_demo_size(size).name
return f"{DEMO_PROFILE}:{name}"
def is_demo_profile(profile: str | None) -> bool:
if not profile:
return False
return profile == DEMO_PROFILE or profile.startswith(f"{DEMO_PROFILE}:")
def list_demo_sizes() -> list[dict[str, Any]]:
return [asdict(cfg) for cfg in DEMO_CLUSTER_SIZES.values()]
# Backward-compatible aliases → large cluster (default demo).
DEMO_SERVER_COUNT = DEMO_CLUSTER_SIZES["large"].servers
DEMO_VOLUME_COUNT = DEMO_CLUSTER_SIZES["large"].volumes
DEMO_HYPERVISOR_COUNT = DEMO_CLUSTER_SIZES["large"].hypervisors
DEMO_IRONIC_COUNT = DEMO_CLUSTER_SIZES["large"].ironic_nodes
DEMO_LB_COUNT = DEMO_CLUSTER_SIZES["large"].loadbalancers
DEMO_STACK_COUNT = DEMO_CLUSTER_SIZES["large"].stacks
DEMO_FIP_COUNT = DEMO_CLUSTER_SIZES["large"].floating_ips
AZS = ("az-1", "az-2", "az-3")
@@ -104,9 +199,32 @@ async def clear_openstack_state(conn: Connection) -> None:
)
async def seed_openstack_demo(conn: Connection, *, password: str = "secret") -> dict[str, Any]:
async def seed_openstack_demo(
conn: Connection,
*,
size: str = DEMO_SIZE_DEFAULT,
password: str = "secret",
) -> dict[str, Any]:
"""Load a full synthetic OpenStack cloud. Replaces prior OpenStack state."""
cfg = resolve_demo_size(size)
server_count = cfg.servers
volume_count = cfg.volumes
hypervisor_count = cfg.hypervisors
ironic_count = cfg.ironic_nodes
lb_count = cfg.loadbalancers
stack_count = cfg.stacks
fip_count = cfg.floating_ips
surface_sample_count = cfg.surface_samples
server_group_count = cfg.server_groups
nested_sample_count = cfg.nested_samples
pack_per_type = cfg.pack_per_type
extra_networks = cfg.extra_networks
extra_security_groups = cfg.extra_security_groups
keypairs_per_user = cfg.keypairs_per_user
edge_routers = cfg.edge_routers
profile = demo_profile_name(cfg)
await clear_openstack_state(conn)
pw = hash_secret(password, salt=b"openstack-sim-v1")
@@ -185,10 +303,10 @@ async def seed_openstack_demo(conn: Connection, *, password: str = "secret") ->
'{"available": true}',
)
for i in range(DEMO_HYPERVISOR_COUNT):
for i in range(hypervisor_count):
host = f"compute-{(i // len(AZS)) + 1:02d}.{AZS[i % len(AZS)]}"
az = AZS[i % len(AZS)]
vms_share = DEMO_SERVER_COUNT // DEMO_HYPERVISOR_COUNT
vms_share = max(1, server_count // hypervisor_count)
await conn.execute(
"""INSERT INTO os_hypervisors(
id, hypervisor_hostname, state, status, host_ip, vcpus, vcpus_used,
@@ -223,7 +341,7 @@ async def seed_openstack_demo(conn: Connection, *, password: str = "secret") ->
for i, az in enumerate(AZS):
hosts = [
f"compute-{(j // len(AZS)) + 1:02d}.{az}"
for j in range(i, DEMO_HYPERVISOR_COUNT, len(AZS))
for j in range(i, hypervisor_count, len(AZS))
]
await conn.execute(
"""INSERT INTO os_aggregates(id, name, availability_zone, hosts, metadata)
@@ -377,10 +495,19 @@ async def seed_openstack_demo(conn: Connection, *, password: str = "secret") ->
prefix,
)
# Extra project topology (realistic inventory: multiple nets / SGs / routers)
# Extra project topology — density scales with cluster size.
extra_net_suffixes = ("mgmt", "storage", "dmz", "backup", "ci", "gpu")
sg_tiers = (
("web", "HTTP/S", 443),
("db", "Database tier", 5432),
("cache", "Cache tier", 6379),
("mq", "Message bus", 5672),
("internal", "Internal RPC", 9696),
("monitoring", "Metrics / scrape", 9100),
)
for pname, pid in project_ids.items():
base = cidr_base[pname]
for extra_i, suffix in enumerate(("mgmt", "storage", "dmz"), start=1):
for extra_i, suffix in enumerate(extra_net_suffixes[:extra_networks], start=1):
net_id = oid(f"net:{pname}-{suffix}")
subnet_id = oid(f"subnet:{pname}-{suffix}")
await conn.execute(
@@ -400,11 +527,7 @@ async def seed_openstack_demo(conn: Connection, *, password: str = "secret") ->
f"10.{base}.{extra_i * 10}.0/24",
f"10.{base}.{extra_i * 10}.1",
)
for sg_name, desc in (
("web", f"HTTP/S for {pname}"),
("db", f"Database tier for {pname}"),
("cache", f"Cache tier for {pname}"),
):
for sg_name, desc_prefix, port in sg_tiers[:extra_security_groups]:
sg_id = oid(f"sg:{pname}-{sg_name}")
await conn.execute(
"""INSERT INTO os_security_groups(id, project_id, name, description)
@@ -412,7 +535,7 @@ async def seed_openstack_demo(conn: Connection, *, password: str = "secret") ->
sg_id,
pid,
sg_name,
desc,
f"{desc_prefix} for {pname}",
)
await conn.execute(
"""INSERT INTO os_security_group_rules(
@@ -422,30 +545,35 @@ async def seed_openstack_demo(conn: Connection, *, password: str = "secret") ->
oid(f"sgrule:{pname}:{sg_name}"),
sg_id,
pid,
443 if sg_name == "web" else (5432 if sg_name == "db" else 6379),
443 if sg_name == "web" else (5432 if sg_name == "db" else 6379),
port,
port,
)
# Secondary edge routers (0 on tiny clusters, more on big).
for edge_i in range(edge_routers):
await conn.execute(
"""INSERT INTO os_routers(id, project_id, name, status, admin_state_up, external_gateway_info)
VALUES($1,$2,$3,'ACTIVE',true,$4::jsonb)""",
oid(f"router:{pname}-edge-{edge_i}"),
pid,
f"{pname}-edge-router-{edge_i}" if edge_routers > 1 else f"{pname}-edge-router",
json.dumps(
{
"network_id": str(public_net),
"enable_snat": True,
"external_fixed_ips": [
{
"ip_address": f"203.0.113.{base + 1 + edge_i}",
"subnet_id": str(public_subnet),
}
],
}
),
)
# Secondary router (HA / edge)
await conn.execute(
"""INSERT INTO os_routers(id, project_id, name, status, admin_state_up, external_gateway_info)
VALUES($1,$2,$3,'ACTIVE',true,$4::jsonb)""",
oid(f"router:{pname}-edge"),
pid,
f"{pname}-edge-router",
json.dumps(
{
"network_id": str(public_net),
"enable_snat": True,
"external_fixed_ips": [
{"ip_address": f"203.0.113.{base + 1}", "subnet_id": str(public_subnet)}
],
}
),
)
# Keypairs (several per user — list is user-scoped)
# Keypairs (several per user — list is user-scoped; count scales with size)
keypair_suffixes = ("key", "deploy", "ci", "bastion", "ops", "batch", "gpu", "lab")
for uname, uid in user_ids.items():
for suffix in ("key", "deploy", "ci", "bastion"):
for suffix in keypair_suffixes[:keypairs_per_user]:
await conn.execute(
"""INSERT INTO os_keypairs(name, user_id, fingerprint, public_key, type)
VALUES($1,$2,$3,$4,'ssh')""",
@@ -459,12 +587,12 @@ async def seed_openstack_demo(conn: Connection, *, password: str = "secret") ->
tenant_cycle = ("admin", "demo", "production", "staging", "development", "demo")
hypervisor_names = [
f"compute-{(i // len(AZS)) + 1:02d}.{AZS[i % len(AZS)]}"
for i in range(DEMO_HYPERVISOR_COUNT)
for i in range(hypervisor_count)
]
server_rows = []
port_rows = []
for i in range(DEMO_SERVER_COUNT):
for i in range(server_count):
pname = tenant_cycle[i % len(tenant_cycle)]
pid = project_ids[pname]
net_id, _subnet = project_nets[pname]
@@ -546,17 +674,40 @@ async def seed_openstack_demo(conn: Connection, *, password: str = "secret") ->
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb)""",
port_rows,
)
# One create action per server so GET …/os-instance-actions is never empty.
action_rows = [
(
oid(f"nova:instance_action:create:{i}"),
row[1], # project_id
f"create-{i}",
json.dumps(
{
"action": "create",
"instance_uuid": str(row[0]),
"server_id": str(row[0]),
"request_id": f"req-seed-{str(row[0])[:8]}",
"message": None,
}
),
)
for i, row in enumerate(server_rows)
]
await conn.executemany(
"""INSERT INTO os_api_objects(id, service, resource_type, project_id, name, status, data)
VALUES($1,'nova','instance_action',$2,$3,'DONE',$4::jsonb)""",
action_rows,
)
# Volumes
volume_rows = []
for i in range(DEMO_VOLUME_COUNT):
for i in range(volume_count):
pname = tenant_cycle[i % len(tenant_cycle)]
volume_rows.append(
(
oid(f"volume:demo:{i}"),
project_ids[pname],
f"vol-{pname}-{i:04d}",
"in-use" if i < DEMO_SERVER_COUNT // 2 else "available",
"in-use" if i < server_count // 2 else "available",
(i % 5 + 1) * 10,
"lvmdriver-1" if i % 3 else "ceph",
i % 11 == 0,
@@ -571,10 +722,10 @@ async def seed_openstack_demo(conn: Connection, *, password: str = "secret") ->
# Per-server nested rows so any listed server has DB-backed attachments/allocations.
attachment_rows = []
for i in range(DEMO_SERVER_COUNT):
for i in range(server_count):
pname = tenant_cycle[i % len(tenant_cycle)]
sid = str(oid(f"server:demo:{i}"))
vid = str(oid(f"volume:demo:{i % DEMO_VOLUME_COUNT}"))
vid = str(oid(f"volume:demo:{i % volume_count}"))
port_id = str(oid(f"port:demo:{i}"))
pid = project_ids[pname]
attachment_rows.append(
@@ -635,7 +786,7 @@ async def seed_openstack_demo(conn: Connection, *, password: str = "secret") ->
)
# Floating IPs
for i in range(DEMO_FIP_COUNT):
for i in range(fip_count):
pname = tenant_cycle[i % len(tenant_cycle)]
await conn.execute(
"""INSERT INTO os_floating_ips(
@@ -650,7 +801,7 @@ async def seed_openstack_demo(conn: Connection, *, password: str = "secret") ->
)
# Ironic nodes
for i in range(DEMO_IRONIC_COUNT):
for i in range(ironic_count):
await conn.execute(
"""INSERT INTO os_nodes(
id, name, driver, provision_state, power_state, resource_class,
@@ -664,7 +815,7 @@ async def seed_openstack_demo(conn: Connection, *, password: str = "secret") ->
)
# Octavia LBs
for i in range(DEMO_LB_COUNT):
for i in range(lb_count):
pname = tenant_cycle[i % len(tenant_cycle)]
await conn.execute(
"""INSERT INTO os_loadbalancers(
@@ -686,7 +837,7 @@ async def seed_openstack_demo(conn: Connection, *, password: str = "secret") ->
)
# Heat stacks
for i in range(DEMO_STACK_COUNT):
for i in range(stack_count):
pname = tenant_cycle[i % len(tenant_cycle)]
await conn.execute(
"""INSERT INTO os_stacks(
@@ -724,7 +875,7 @@ async def seed_openstack_demo(conn: Connection, *, password: str = "secret") ->
# Generic service samples (multiple per service) — keep resource_type aligned
# with pack operation resource_type so schema list endpoints return rows.
samples = []
for i in range(8):
for i in range(surface_sample_count):
samples.extend(
[
("barbican", "secret", f"secret-{i}", {"secret_type": "passphrase"}),
@@ -1342,11 +1493,12 @@ async def seed_openstack_demo(conn: Connection, *, password: str = "secret") ->
)
# Nova server groups (specialized table) — denser in demo project
for i in range(16):
pname = "demo" if i < 8 else tenant_cycle[i % len(tenant_cycle)]
for i in range(server_group_count):
pname = "demo" if i < max(1, server_group_count // 2) else tenant_cycle[i % len(tenant_cycle)]
span = max(1, min(8, max(1, server_count // 4)))
members = [
str(oid(f"server:demo:{3 + (i % 8) * 6}")),
str(oid(f"server:demo:{3 + ((i + 1) % 8) * 6}")),
str(oid(f"server:demo:{(3 + (i % span) * 6) % server_count}")),
str(oid(f"server:demo:{(3 + ((i + 1) % span) * 6) % server_count}")),
]
await conn.execute(
"""INSERT INTO os_server_groups(id, project_id, name, policies, members)
@@ -1368,14 +1520,15 @@ async def seed_openstack_demo(conn: Connection, *, password: str = "secret") ->
demo_trunk = str(oid("neutron:trunk:trunk-0"))
demo_local_ip = str(oid("neutron:local_ip:lip-0"))
demo_bgpvpn = str(oid("neutron:bgpvpn:bgpvpn-0"))
demo_stack_id = str(oid("stack:demo:3"))
demo_stack_name = "stack-demo-03"
demo_stack_idx = min(3, max(0, stack_count - 1))
demo_stack_id = str(oid(f"stack:demo:{demo_stack_idx}"))
demo_stack_name = f"stack-demo-{demo_stack_idx:02d}"
nested_samples: list[tuple[str, str, str, dict[str, Any]]] = []
# Cover the first listed servers (and a wider spread) so nested GETs hit DB rows.
for i in range(24):
sidx = i
for i in range(nested_sample_count):
sidx = i % server_count
sid = str(oid(f"server:demo:{sidx}"))
vid = str(oid(f"volume:demo:{sidx}"))
vid = str(oid(f"volume:demo:{sidx % volume_count}"))
port_id = str(oid(f"port:demo:{sidx}"))
nested_samples.extend(
[
@@ -1638,24 +1791,56 @@ async def seed_openstack_demo(conn: Connection, *, password: str = "secret") ->
for service, rtype, name, data in nested_samples:
item_id = oid(f"{service}:{rtype}:{name}")
payload = {"id": str(item_id), "name": name, "status": data.get("status", "ACTIVE"), **data}
# Scope nested rows to the parent server's project (tenant_cycle index).
owner_pid = demo_pid
server_ref = str(data.get("server_id") or data.get("instance_uuid") or "")
for idx in range(min(server_count, 64)):
if server_ref == str(oid(f"server:demo:{idx}")):
owner_pid = project_ids[tenant_cycle[idx % len(tenant_cycle)]]
break
await conn.execute(
"""INSERT INTO os_api_objects(id, service, resource_type, project_id, name, status, data)
VALUES($1,$2,$3,$4,$5,$6,$7::jsonb)""",
item_id,
service,
rtype,
None, # shared — nested lists visible for any project token
owner_pid,
name,
str(payload.get("status") or "ACTIVE"),
json.dumps(payload),
)
# Quotas as api objects (name=project id so Neutron-style /quotas/{project_id} resolves)
quota_factor = max(1.0, server_count / 1000)
for pname, pid in project_ids.items():
for svc, rtype, data in (
("nova", "quota_set", {"instances": 200, "cores": 800, "ram": 1_024_000}),
("cinder", "quota_set", {"volumes": 200, "gigabytes": 50_000}),
("neutron", "quota", {"network": 50, "subnet": 100, "port": 500, "floatingip": 50}),
(
"nova",
"quota_set",
{
"instances": _scale(200, quota_factor, 40),
"cores": _scale(800, quota_factor, 80),
"ram": _scale(1_024_000, quota_factor, 64_000),
},
),
(
"cinder",
"quota_set",
{
"volumes": _scale(200, quota_factor, 40),
"gigabytes": _scale(50_000, quota_factor, 5_000),
},
),
(
"neutron",
"quota",
{
"network": _scale(50, quota_factor, 10),
"subnet": _scale(100, quota_factor, 20),
"port": _scale(500, quota_factor, 80),
"floatingip": _scale(50, quota_factor, 10),
},
),
):
await conn.execute(
"""INSERT INTO os_api_objects(id, service, resource_type, project_id, name, status, data)
@@ -1675,31 +1860,44 @@ async def seed_openstack_demo(conn: Connection, *, password: str = "secret") ->
from app.openstack.seed_discovery import seed_discovery_documents
discovery = await seed_discovery_documents(conn)
pack_seed = await seed_pack_surface_samples(conn, per_type=3)
pack_seed = await seed_pack_surface_samples(conn, per_type=pack_per_type)
pack_seed = {**pack_seed, **discovery}
await conn.execute(
"""INSERT INTO os_demo_meta(key, value) VALUES('profile', $1), ('servers', $2), ('password', $3)""",
DEMO_PROFILE,
str(DEMO_SERVER_COUNT),
"""INSERT INTO os_demo_meta(key, value)
VALUES('profile', $1), ('size', $2), ('servers', $3), ('password', $4)""",
profile,
cfg.name,
str(server_count),
password,
)
return {
"profile": DEMO_PROFILE,
"servers": DEMO_SERVER_COUNT,
"profile": profile,
"size": cfg.name,
"servers": server_count,
"pack_seed": pack_seed,
"volumes": DEMO_VOLUME_COUNT,
"hypervisors": DEMO_HYPERVISOR_COUNT,
"volumes": volume_count,
"hypervisors": hypervisor_count,
"ironic_nodes": ironic_count,
"loadbalancers": lb_count,
"stacks": stack_count,
"floating_ips": fip_count,
"extra_networks": extra_networks,
"extra_security_groups": extra_security_groups,
"keypairs_per_user": keypairs_per_user,
"edge_routers": edge_routers,
"projects": list(project_ids.keys()),
"users": list(user_ids.keys()),
"password": password,
"availability_zones": list(AZS),
"cluster": asdict(cfg),
}
async def openstack_demo_summary(conn: Connection) -> dict[str, Any]:
profile = await conn.fetchval("SELECT value FROM os_demo_meta WHERE key='profile'")
size = await conn.fetchval("SELECT value FROM os_demo_meta WHERE key='size'")
servers = await conn.fetchval("SELECT count(*) FROM os_servers")
volumes = await conn.fetchval("SELECT count(*) FROM os_volumes")
networks = await conn.fetchval("SELECT count(*) FROM os_networks")
@@ -1712,9 +1910,14 @@ async def openstack_demo_summary(conn: Connection) -> dict[str, Any]:
stacks = await conn.fetchval("SELECT count(*) FROM os_stacks")
nodes = await conn.fetchval("SELECT count(*) FROM os_nodes")
fips = await conn.fetchval("SELECT count(*) FROM os_floating_ips")
loaded = is_demo_profile(profile)
cfg = DEMO_CLUSTER_SIZES.get(str(size or ""), None)
if cfg is None and loaded and isinstance(profile, str) and ":" in profile:
cfg = DEMO_CLUSTER_SIZES.get(profile.rsplit(":", 1)[-1])
return {
"loaded": profile == DEMO_PROFILE,
"loaded": loaded,
"profile": profile or "minimal",
"size": (cfg.name if cfg else size) or None,
"servers": int(servers or 0),
"volumes": int(volumes or 0),
"networks": int(networks or 0),
@@ -1727,5 +1930,6 @@ async def openstack_demo_summary(conn: Connection) -> dict[str, Any]:
"stacks": int(stacks or 0),
"ironic_nodes": int(nodes or 0),
"floating_ips": int(fips or 0),
"target_servers": DEMO_SERVER_COUNT,
"target_servers": cfg.servers if cfg else int(servers or 0),
"sizes": list_demo_sizes(),
}
+1 -10
View File
@@ -14,21 +14,12 @@ from fastapi.responses import JSONResponse
from app.openstack.auth import TokenContext
from app.openstack.deps import get_conn, require_project_token, require_token
from app.openstack.errors import OpenStackError
from app.openstack.singular import singular as _singular
from app.openstack.surface import SERVICES, ServiceSpec
_PATH_PARAM = re.compile(r"\{([^{}]+)\}")
def _singular(collection_key: str) -> str:
if collection_key.endswith("ies"):
return collection_key[:-3] + "y"
if collection_key.endswith("ses"):
return collection_key[:-2]
if collection_key.endswith("s") and not collection_key.endswith("ss"):
return collection_key[:-1]
return collection_key
def _wrap_list(collection_key: str, items: list[dict[str, Any]]) -> dict[str, Any]:
return {collection_key: items}
+2
View File
@@ -29,6 +29,8 @@ class OperationSpec:
action_name: str | None = None
response_fixture: dict[str, Any] | None = None
notes: str = ""
# Full JSON Schema for the HTTP request body (merged from request_bodies/).
request_schema: dict[str, Any] | None = None
def path_params(self) -> list[str]:
import re
+6 -1
View File
@@ -190,11 +190,16 @@ def register_openstack_contract_routes(
full_path = f"/_os/{pack.name}{path}"
name = f"{_ROUTE_NAME_PREFIX}{pack.name}:{op.method}:{op.path}"
endpoint = _make_contract_endpoint(pack, op, handlers, dispatch_fn)
# FastAPI only auto-adds HEAD for @app.get(); contract routes use
# add_api_route — register HEAD beside every GET for the matrix.
methods = [op.method]
if op.method.upper() == "GET":
methods.append("HEAD")
app.add_api_route(
full_path,
endpoint,
methods=[op.method],
methods=methods,
name=name,
include_in_schema=True,
tags=[service_openapi_tag(pack.name)],
+127
View File
@@ -0,0 +1,127 @@
"""Load and resolve OpenStack request-body JSON Schemas.
Schemas live in ``contracts/openstack/request_bodies/<service>.json`` and are
merged onto ``OperationSpec`` at pack load time (shared across series).
"""
from __future__ import annotations
import json
from dataclasses import replace
from functools import lru_cache
from pathlib import Path
from typing import Any
from app.openstack.opspec import OperationSpec
_BODIES_ROOT = Path(__file__).resolve().parents[2] / "contracts" / "openstack" / "request_bodies"
def request_bodies_root() -> Path:
return _BODIES_ROOT
@lru_cache(maxsize=1)
def _load_all() -> dict[str, dict[str, Any]]:
root = request_bodies_root()
out: dict[str, dict[str, Any]] = {}
if not root.is_dir():
return out
for path in sorted(root.glob("*.json")):
data = json.loads(path.read_text())
service = str(data.get("service") or path.stem)
ops = data.get("operations") or {}
by_path = data.get("by_path") or {}
out[service] = {"operations": dict(ops), "by_path": dict(by_path)}
return out
def clear_request_body_cache() -> None:
_load_all.cache_clear()
def _path_key(method: str, path: str) -> str:
return f"{method.upper()} {path}"
def lookup_request_schema(service: str, op: OperationSpec) -> dict[str, Any] | None:
"""Return the JSON Schema for an operation, or None if missing."""
store = _load_all().get(service) or {}
ops = store.get("operations") or {}
schema = ops.get(op.operation_id)
if schema is None:
schema = (store.get("by_path") or {}).get(_path_key(op.method, op.path))
if schema is None:
return None
return _resolve_schema(schema, microversion=op.microversion_max)
def _resolve_schema(schema: dict[str, Any], *, microversion: str | None) -> dict[str, Any]:
"""Pick a concrete schema from OpenStack oneOf discriminators when present."""
if not isinstance(schema, dict):
return {}
if "oneOf" in schema and isinstance(schema["oneOf"], list):
xos = schema.get("x-openstack") or {}
discriminator = xos.get("discriminator")
variants = [item for item in schema["oneOf"] if isinstance(item, dict)]
if discriminator == "action":
# Prefer first variant; callers match action via separate ops.
chosen = variants[0] if variants else schema
return _resolve_schema(chosen, microversion=microversion)
if discriminator == "microversion" and microversion:
best: dict[str, Any] | None = None
for item in variants:
meta = item.get("x-openstack") or {}
min_ver = str(meta.get("min-ver") or "0")
max_ver = meta.get("max-ver")
if _mv_le(min_ver, microversion) and (
max_ver is None or _mv_le(microversion, str(max_ver))
):
best = item
if best is not None:
return _resolve_schema(best, microversion=microversion)
if variants:
return _resolve_schema(variants[-1], microversion=microversion)
return schema
def _mv_le(left: str, right: str) -> bool:
def parts(value: str) -> tuple[int, ...]:
out: list[int] = []
for piece in value.split("."):
try:
out.append(int(piece))
except ValueError:
out.append(0)
return tuple(out)
return parts(left) <= parts(right)
def attach_request_schemas(service: str, ops: list[OperationSpec]) -> list[OperationSpec]:
"""Return new OperationSpec list with ``request_schema`` filled where known."""
attached: list[OperationSpec] = []
for op in ops:
schema = lookup_request_schema(service, op)
if schema is None:
attached.append(op)
continue
attached.append(replace(op, request_schema=schema))
return attached
def missing_write_schemas(packs: dict[str, Any]) -> list[tuple[str, str, str, str]]:
"""List (service, method, path, operation_id) missing request schemas."""
missing: list[tuple[str, str, str, str]] = []
for name, pack in sorted(packs.items()):
for op in pack.operations:
if op.method not in {"POST", "PUT", "PATCH"}:
continue
if op.request_schema:
continue
missing.append((name, op.method, op.path, op.operation_id))
return missing
+262
View File
@@ -0,0 +1,262 @@
"""Build UI body_fields / body_example from JSON Schema request bodies."""
from __future__ import annotations
from typing import Any
def schema_example(schema: dict[str, Any] | None, *, name: str | None = None) -> Any:
"""Build a representative JSON value from a JSON Schema fragment."""
if not schema:
return None
if "example" in schema:
return schema["example"]
if "default" in schema:
return schema["default"]
enum_values = schema.get("enum") or []
if enum_values:
return enum_values[0]
if "const" in schema:
return schema["const"]
schema_type = schema.get("type")
if isinstance(schema_type, list):
schema_type = next((t for t in schema_type if t != "null"), schema_type[0])
if schema_type == "object" or ("properties" in schema and schema_type is None):
props = schema.get("properties") or {}
required = set(schema.get("required") or [])
out: dict[str, Any] = {}
for key, child in props.items():
if not isinstance(child, dict):
continue
# Include required always; also optional fields that declare example/default.
include = key in required or "example" in child or "default" in child
if not include:
# Still include optional leaves for api-ref-style full previews.
include = True
if include:
out[key] = schema_example(child, name=key)
return out
if schema_type == "array":
items = schema.get("items")
if isinstance(items, dict):
return [schema_example(items, name=name)]
return []
if schema_type == "boolean":
return False
if schema_type == "integer":
minimum = schema.get("minimum")
return int(minimum) if minimum is not None else 1
if schema_type == "number":
minimum = schema.get("minimum")
return float(minimum) if minimum is not None else 1.0
if schema_type == "null":
return None
# string / unknown
fmt = schema.get("format")
if fmt == "uri" or fmt == "url":
return "http://example.com"
if fmt == "email":
return "user@example.com"
if fmt == "uuid" or (name and (name.endswith("_id") or name == "id")):
return "00000000-0000-0000-0000-000000000001"
if name in {"name", "stack_name", "display_name"}:
return "example"
if name in {"cidr", "remote_ip_prefix"}:
return "10.0.0.0/24"
if name in {"password"}:
return "secret"
return "example"
def flatten_schema_fields(
schema: dict[str, Any] | None,
*,
prefix: str = "",
max_depth: int = 6,
) -> list[dict[str, Any]]:
"""Flatten a JSON Schema into dotted PARAM field descriptors for the console."""
if not schema or max_depth < 0:
return []
schema_type = schema.get("type")
if isinstance(schema_type, list):
schema_type = next((t for t in schema_type if t != "null"), schema_type[0])
props = schema.get("properties")
if (schema_type == "object" or props) and isinstance(props, dict):
required = set(schema.get("required") or [])
fields: list[dict[str, Any]] = []
for key, child in props.items():
if not isinstance(child, dict):
continue
path = f"{prefix}.{key}" if prefix else key
child_type = child.get("type")
if isinstance(child_type, list):
child_type = next((t for t in child_type if t != "null"), child_type[0])
nested_props = child.get("properties")
if (child_type == "object" or nested_props) and isinstance(nested_props, dict):
fields.extend(
flatten_schema_fields(child, prefix=path, max_depth=max_depth - 1)
)
elif child_type == "array":
items = child.get("items")
if isinstance(items, dict) and (
items.get("type") == "object" or isinstance(items.get("properties"), dict)
):
# Expand one sample element so nested array object fields appear.
fields.extend(
flatten_schema_fields(
items, prefix=f"{path}.0", max_depth=max_depth - 1
)
)
else:
fields.append(
{
"name": path,
"type": "array",
"description": child.get("description") or f"Array {path}",
"optional": key not in required,
"enum": list(child.get("enum") or []),
"example": schema_example(child, name=key),
}
)
else:
fields.append(
{
"name": path,
"type": str(child_type or "string"),
"description": child.get("description"),
"optional": key not in required,
"enum": list(child.get("enum") or []),
"example": schema_example(child, name=key),
}
)
return fields
if prefix:
return [
{
"name": prefix,
"type": str(schema_type or "string"),
"description": schema.get("description"),
"optional": False,
"enum": list(schema.get("enum") or []),
"example": schema_example(schema, name=prefix),
}
]
return []
def body_fields_from_example(body_example: dict[str, Any] | None) -> list[dict[str, Any]]:
"""PARAM inputs derived from body_example, including nested scalar paths.
Mirrors oVirt console behaviour: walk the Engine/OpenStack-shaped example and
emit one PARAM row per leaf (dotted paths, numeric segments for arrays).
"""
if not isinstance(body_example, dict) or not body_example:
return []
fields: list[dict[str, Any]] = []
def _leaf_type(value: Any) -> str:
if isinstance(value, bool):
return "boolean"
if isinstance(value, int) and not isinstance(value, bool):
return "integer"
if isinstance(value, float):
return "number"
if value is None:
return "null"
return "string"
def _walk(prefix: str, value: Any) -> None:
if isinstance(value, dict):
for key, child in value.items():
path = f"{prefix}.{key}" if prefix else str(key)
_walk(path, child)
return
if isinstance(value, list):
if not value:
fields.append(
{
"name": prefix,
"type": "array",
"description": prefix,
"optional": True,
"enum": [],
"example": [],
}
)
return
for index, child in enumerate(value):
path = f"{prefix}.{index}" if prefix else str(index)
_walk(path, child)
return
fields.append(
{
"name": prefix,
"type": _leaf_type(value),
"description": prefix,
"optional": True,
"enum": [],
"example": value,
}
)
_walk("", body_example)
return fields
def unflatten_body(values: dict[str, Any]) -> dict[str, Any]:
"""Turn dotted keys into a nested dict (supports numeric array segments)."""
root: dict[str, Any] = {}
def _set(path: str, value: Any) -> None:
parts = path.split(".")
cur: Any = root
for i, part in enumerate(parts[:-1]):
nxt = parts[i + 1]
want_array = nxt.isdigit()
if part.isdigit():
idx = int(part)
if not isinstance(cur, list):
return
while len(cur) <= idx:
cur.append([] if want_array else {})
if cur[idx] is None or (want_array and not isinstance(cur[idx], list)) or (
not want_array and not isinstance(cur[idx], dict)
):
cur[idx] = [] if want_array else {}
cur = cur[idx]
continue
if want_array:
if not isinstance(cur.get(part), list):
cur[part] = []
elif not isinstance(cur.get(part), dict):
cur[part] = {}
cur = cur[part]
leaf = parts[-1]
if leaf.isdigit():
idx = int(leaf)
if not isinstance(cur, list):
return
while len(cur) <= idx:
cur.append(None)
cur[idx] = value
return
if isinstance(cur, dict):
cur[leaf] = value
for dotted, value in sorted(values.items(), key=lambda item: item[0].count(".")):
if value is None or value == "":
continue
_set(dotted, value)
return root
+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,
+2 -13
View File
@@ -17,20 +17,11 @@ from app.openstack.auth import TokenContext, extract_token, validate_token
from app.openstack.contract_loader import ensure_loaded, get_runtime
from app.openstack.errors import OpenStackError
from app.openstack.opspec import OperationSpec, ServicePack
from app.openstack.singular import singular as _singular
_PATH_PARAM = re.compile(r"\{([^{}]+)\}")
def _singular(collection_key: str) -> str:
if collection_key.endswith("ies"):
return collection_key[:-3] + "y"
if collection_key.endswith("ses"):
return collection_key[:-2]
if collection_key.endswith("s") and not collection_key.endswith("ss"):
return collection_key[:-1]
return collection_key
def _fastapi_path(path: str) -> str:
"""Convert {param} to FastAPI {param} (already compatible)."""
return path if path.startswith("/") else f"/{path}"
@@ -44,9 +35,7 @@ def _parent_scope(path: str, path_params: dict[str, str]) -> dict[str, str]:
match = re.search(r"/([^/]+)/\{id\}(?:/|$)", path)
if match:
segment = match.group(1)
singular = (
segment[:-1] if segment.endswith("s") and not segment.endswith("ss") else segment
)
singular = _singular(segment)
parent.setdefault(f"{singular}_id", path_params["id"])
parent.setdefault(singular, path_params["id"])
parent.setdefault("parent_id", path_params["id"])
+92 -1
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
import json
from asyncpg import Connection
from app.openstack.ids import oid
@@ -149,6 +151,22 @@ async def seed_openstack(conn: Connection, *, password: str = "secret") -> dict[
'{"demo-net":[{"OS-EXT-IPS-MAC:mac_addr":"fa:16:3e:00:00:01","version":4,"addr":"10.0.0.12","OS-EXT-IPS:type":"fixed"}]}',
'{"env":"lab","_tags":["lab","env","demo"]}',
)
await conn.execute(
"""INSERT INTO os_api_objects(id, service, resource_type, project_id, name, status, data)
VALUES($1,'nova','instance_action',$2,'create','DONE',$3::jsonb)
ON CONFLICT (id) DO NOTHING""",
oid("nova:instance_action:demo-1"),
demo_project,
json.dumps(
{
"action": "create",
"instance_uuid": str(server),
"server_id": str(server),
"request_id": f"req-seed-{str(server)[:8]}",
"message": None,
}
),
)
await seed_openstack_extras(conn)
@@ -226,12 +244,85 @@ async def seed_openstack_extras(conn: Connection) -> None:
prefix,
)
# Shared external provider network for floating IPs / router gateways.
public_net = oid("net:public")
await conn.execute(
"""INSERT INTO os_networks(id, project_id, name, status, shared, admin_state_up)
VALUES($1,$2,'public','ACTIVE',true,true) ON CONFLICT (id) DO NOTHING""",
public_net,
admin_project,
)
public_subnet = oid("subnet:public")
await conn.execute(
"""INSERT INTO os_subnets(id, network_id, project_id, name, cidr, ip_version, gateway_ip)
VALUES($1,$2,$3,'public-subnet','203.0.113.0/24',4,'203.0.113.1')
ON CONFLICT (id) DO NOTHING""",
public_subnet,
public_net,
admin_project,
)
await conn.execute(
"""INSERT INTO os_routers(id, project_id, name, status, admin_state_up, external_gateway_info)
VALUES($1,$2,'demo-router','ACTIVE',true,NULL) ON CONFLICT (id) DO NOTHING""",
VALUES($1,$2,'demo-router','ACTIVE',true,$3::jsonb) ON CONFLICT (id) DO NOTHING""",
oid("router:demo"),
demo_project,
json.dumps(
{
"network_id": str(public_net),
"enable_snat": True,
"external_fixed_ips": [
{"ip_address": "203.0.113.2", "subnet_id": str(public_subnet)}
],
}
),
)
await conn.execute(
"""INSERT INTO os_floating_ips(
id, project_id, floating_ip_address, floating_network_id, port_id, status)
VALUES($1,$2,'203.0.113.50',$3,NULL,'DOWN') ON CONFLICT (id) DO NOTHING""",
oid("fip:demo"),
demo_project,
public_net,
)
demo_net = oid("net:demo-net")
demo_subnet = oid("subnet:demo-subnet")
demo_server = oid("server:demo-1")
await conn.execute(
"""INSERT INTO os_ports(
id, network_id, project_id, name, status, mac_address,
device_id, device_owner, fixed_ips)
VALUES(
$1,$2,$3,'demo-port','ACTIVE','fa:16:3e:00:00:aa',
$4,'compute:nova',
$5::jsonb
) ON CONFLICT (id) DO NOTHING""",
oid("port:demo"),
demo_net,
demo_project,
str(demo_server),
json.dumps([{"subnet_id": str(demo_subnet), "ip_address": "10.0.0.12"}]),
)
await conn.execute(
"""INSERT INTO os_server_groups(id, project_id, name, policies, members)
VALUES($1,$2,'demo-sg',$3::jsonb,'[]'::jsonb) ON CONFLICT (id) DO NOTHING""",
oid("sgroup:demo"),
demo_project,
json.dumps(["soft-anti-affinity"]),
)
try:
await conn.execute(
"""INSERT INTO os_aggregates(id, name, availability_zone, hosts, metadata)
VALUES(1,'agg-nova','nova','["compute-1"]'::jsonb,'{}'::jsonb)
ON CONFLICT (id) DO NOTHING"""
)
await conn.execute(
"""INSERT INTO os_compute_services("binary", host, zone, status, state)
VALUES('nova-compute','compute-1','nova','enabled','up')"""
)
except Exception:
# Topology tables from migration 011 may be absent in partial installs.
pass
await conn.execute(
"""INSERT INTO os_nodes(id, name, driver, provision_state, power_state, resource_class, properties, driver_info, ports)
+17 -7
View File
@@ -6,12 +6,16 @@ import argparse
import asyncio
import json
import os
import sys
import asyncpg
from app.config import get_settings
from app.openstack.demo_cloud import clear_openstack_state, seed_openstack_demo
from app.openstack.demo_cloud import (
DEMO_CLUSTER_SIZES,
clear_openstack_state,
resolve_demo_size,
seed_openstack_demo,
)
from app.openstack.seed import seed_openstack
@@ -20,22 +24,28 @@ async def _run(profile: str, password: str) -> dict[str, object]:
conn = await asyncpg.connect(settings.database_url.get_secret_value())
try:
async with conn.transaction():
if profile in {"demo", "demo-cloud", "openstack-demo-cloud"}:
return await seed_openstack_demo(conn, password=password)
if profile in {"minimal", "lab", "small"}:
key = profile.strip().lower()
if key in {"minimal", "lab"}:
await clear_openstack_state(conn)
return await seed_openstack(conn, password=password)
raise SystemExit(f"unknown profile: {profile} (use minimal|demo)")
# demo / demo-small / small / large / big / openstack-demo-cloud:…
try:
cfg = resolve_demo_size(key)
except ValueError as exc:
known = "minimal | demo | demo-small | demo-large | demo-big | small | large | big"
raise SystemExit(f"unknown profile: {profile} (use {known})") from exc
return await seed_openstack_demo(conn, size=cfg.name, password=password)
finally:
await conn.close()
def main(argv: list[str] | None = None) -> int:
sizes = ", ".join(sorted(DEMO_CLUSTER_SIZES))
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--profile",
default=os.environ.get("SEED_PROFILE", "minimal"),
help="minimal | demo",
help=f"minimal | demo | demo-small | demo-large | demo-big | {sizes}",
)
parser.add_argument("--password", default=os.environ.get("OS_PASSWORD", "secret"))
args = parser.parse_args(argv)
+33
View File
@@ -0,0 +1,33 @@
"""Shared plural→singular helpers for OpenStack resource keys."""
from __future__ import annotations
# Words that already look plural-ending but must not lose a trailing "s".
_IRREGULAR: dict[str, str] = {
"status": "status",
"statuses": "status",
"addresses": "address",
"quotas": "quota",
"metadata": "metadata",
"series": "series",
"os-services": "os-service",
"os-hosts": "os-host",
}
def singular(collection_key: str) -> str:
"""Return a singular resource key for an OpenStack collection name."""
key = collection_key.strip()
if not key:
return key
lower = key.lower()
if lower in _IRREGULAR:
return _IRREGULAR[lower]
if key.endswith("ies") and len(key) > 3:
return key[:-3] + "y"
if key.endswith("ses") and len(key) > 3:
return key[:-2]
if key.endswith("s") and not key.endswith("ss"):
return key[:-1]
return key
+198 -28
View File
@@ -14,10 +14,13 @@ import urllib.request
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Any
from urllib.parse import urlparse
from uuid import uuid4
from app.openstack.contract_loader import load_series_pack
from app.openstack.opspec import OperationSpec, ServicePack
from app.openstack.singular import singular as _singular
from app.openstack.surface import all_service_ports
_PATH_PARAM = re.compile(r"\{([^{}]+)\}")
@@ -26,6 +29,59 @@ ACCEPTABLE = frozenset({200, 201, 202, 204, 300, 400, 401, 403, 404, 405, 409, 4
# Lifecycle success for exercised CRUD steps.
SUCCESS = frozenset({200, 201, 202, 204})
# OpenStack-API-Version type tokens (service name → header type).
_MV_TYPE = {
"nova": "compute",
"cinder": "volume",
"placement": "placement",
"manila": "share",
"ironic": "baremetal",
}
def gateway_hostname(host: str) -> tuple[str, str]:
"""Return (scheme, hostname) from a Keystone/gateway base URL."""
parsed = urlparse(host if "://" in host else f"http://{host}")
return parsed.scheme or "http", parsed.hostname or "127.0.0.1"
def service_base_url(host: str, service: str, *, port: int | None = None) -> str:
"""Build ``scheme://hostname:<service-port>`` for real OpenStack ports.
``host`` is the Keystone/gateway URL (may use up-local :15000). Service calls
always use the catalog port from ``docs/ports.md`` / ``ServicePack.port``.
"""
scheme, hostname = gateway_hostname(host)
ports = all_service_ports()
svc_port = port if port is not None else ports.get(service)
if svc_port is None:
# Fall back to the host as-is (route-service header still applied).
return host.rstrip("/")
return f"{scheme}://{hostname}:{svc_port}"
def microversion_headers(pack: ServicePack, op: OperationSpec) -> dict[str, str]:
"""Headers when the pack declares a microversion for this service/op."""
ver = op.microversion_min or pack.default_microversion
if not ver:
return {}
typ = _MV_TYPE.get(pack.name) or (pack.typ if pack.typ and pack.typ != pack.name else pack.name)
headers = {"OpenStack-API-Version": f"{typ} {ver}"}
if pack.name == "nova":
headers["X-OpenStack-Nova-API-Version"] = ver
return headers
def count_declared_ops(packs: dict[str, ServicePack]) -> tuple[int, int]:
"""Return (pack_ops, synthetic_head_ops) for coverage arithmetic."""
pack_ops = sum(len(p.operations) for p in packs.values())
head_ops = sum(1 for p in packs.values() for op in p.operations if op.method == "GET")
return pack_ops, head_ops
@dataclass
class ProbeResult:
@@ -123,16 +179,6 @@ def fill_path(template: str, ctx: dict[str, str] | None = None) -> str:
return _PATH_PARAM.sub(repl, template)
def _singular(key: str) -> str:
if key.endswith("ies"):
return key[:-3] + "y"
if key.endswith("ses"):
return key[:-2]
if key.endswith("s") and not key.endswith("ss"):
return key[:-1]
return key
def _body_for(
op: OperationSpec,
*,
@@ -259,27 +305,31 @@ def http_request(
token: str | None = None,
service: str | None = None,
data: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
timeout: float = 20.0,
) -> tuple[int, Any]:
body = None if data is None else json.dumps(data).encode()
headers = {"Accept": "application/json"}
hdrs = {"Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
hdrs["Content-Type"] = "application/json"
if token:
headers["X-Auth-Token"] = token
hdrs["X-Auth-Token"] = token
if service:
headers["X-OpenStack-Route-Service"] = service
req = urllib.request.Request(url, data=body, headers=headers, method=method)
# Fallback when the client cannot reach the real service port.
hdrs["X-OpenStack-Route-Service"] = service
if headers:
hdrs.update(headers)
req = urllib.request.Request(url, data=body, headers=hdrs, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as res:
raw = res.read().decode()
raw = res.read().decode() if method.upper() != "HEAD" else ""
try:
parsed: Any = json.loads(raw) if raw else None
except json.JSONDecodeError:
parsed = raw
return res.status, parsed
except urllib.error.HTTPError as exc:
raw = exc.read().decode()
raw = exc.read().decode() if method.upper() != "HEAD" else ""
try:
parsed = json.loads(raw) if raw else None
except json.JSONDecodeError:
@@ -434,6 +484,7 @@ def probe_operation(
ctx: dict[str, str] | None = None,
project_id: str | None = None,
mode: str = "probe",
method_override: str | None = None,
) -> tuple[ProbeResult, Any]:
path_ctx = dict(ctx or {})
if project_id:
@@ -442,27 +493,72 @@ def probe_operation(
path_ctx.setdefault("tenant_id", project_id)
path_ctx.setdefault("account", project_id)
path = fill_path(op.path, path_ctx)
url = f"{host.rstrip('/')}{path}"
data = _body_for(op, ctx=path_ctx, project_id=project_id)
status, payload = http_request(op.method, url, token=token, service=pack.name, data=data)
base = service_base_url(host, pack.name, port=pack.port)
url = f"{base}{path}"
method = (method_override or op.method).upper()
data = None if method in {"GET", "HEAD", "DELETE"} else _body_for(op, ctx=path_ctx, project_id=project_id)
mv = microversion_headers(pack, op)
status, payload = http_request(
method,
url,
token=token,
service=pack.name,
data=data,
headers=mv or None,
)
detail = ""
check = SUCCESS if mode == "lifecycle" else ACCEPTABLE
# Synthetic HEAD: accept the same codes as probe (no body expected).
if method == "HEAD":
check = ACCEPTABLE
result_mode = "head"
else:
check = SUCCESS if mode == "lifecycle" else ACCEPTABLE
result_mode = mode
if status not in check:
detail = json.dumps(payload)[:300] if not isinstance(payload, str) else str(payload)[:300]
result = ProbeResult(
service=pack.name,
method=op.method,
method=method,
path=op.path,
operation_id=op.operation_id,
operation_id=op.operation_id if method != "HEAD" else f"{op.operation_id}__head",
status=status,
detail=detail,
mode=mode,
mode=result_mode,
payload=payload,
collection_key=op.collection_key,
)
return result, payload
def _append_synthetic_heads(
report: ProbeReport,
packs: dict[str, ServicePack],
*,
host: str,
token: str,
project_id: str,
ctx: dict[str, str],
) -> None:
"""Issue HEAD for every pack GET path (contract matrix Layer A)."""
for name in sorted(packs):
pack = packs[name]
for op in pack.operations:
if op.method != "GET":
continue
result, _ = probe_operation(
host,
pack,
op,
token=token,
ctx=ctx,
project_id=project_id,
mode="probe",
method_override="HEAD",
)
report.results.append(result)
def _seed_context(
host: str,
token: str,
@@ -490,11 +586,33 @@ def _seed_context(
("ironic", "/v1/nodes", "nodes", "node"),
("ironic", "/v1/drivers", "drivers", "driver"),
("octavia", "/v2/lbaas/loadbalancers", "loadbalancers", "loadbalancer"),
("swift", f"/v1/{project_id}", None, "account"),
("swift", f"/v1/AUTH_{project_id}", None, "account"),
]
for service, path, key, alias in seeds:
st, body = http_request("GET", f"{host.rstrip('/')}{path}", token=token, service=service)
if st >= 400 or not isinstance(body, dict):
url = f"{service_base_url(host, service)}{path}"
st, body = http_request("GET", url, token=token, service=service)
if st >= 400:
continue
# Swift account listing is a JSON array, not an envelope object.
if service == "swift" and isinstance(body, list) and body:
ctx["account"] = f"AUTH_{project_id}"
first = body[0] if isinstance(body[0], dict) else {}
if first.get("name"):
ctx["container"] = str(first["name"])
# Seed object name from the first container listing when present.
ost, objs = http_request(
"GET",
f"{service_base_url(host, 'swift')}/v1/{ctx['account']}/{ctx['container']}",
token=token,
service="swift",
)
if ost < 400 and isinstance(objs, list) and objs:
oname = objs[0].get("name") if isinstance(objs[0], dict) else None
if oname:
ctx["object"] = str(oname)
ctx["object_name"] = str(oname)
continue
if not isinstance(body, dict):
continue
ids = _extract_ids(body, key)
if ids:
@@ -527,7 +645,7 @@ def _ensure_swift_resources(host: str, token: str, project_id: str, ctx: dict[st
account = ctx.get("account") or project_id
container = ctx.get("container") or f"probe-c-{uuid4().hex[:8]}"
obj = ctx.get("object") or ctx.get("object_name") or f"probe-o-{uuid4().hex[:8]}.txt"
base = host.rstrip("/")
base = service_base_url(host, "swift")
st, _ = http_request(
"PUT", f"{base}/v1/{account}/{container}", token=token, service="swift", data={}
)
@@ -766,6 +884,34 @@ def probe_series_lifecycle(
local["_item_id"] = rid
local["id"] = rid
local["server_id"] = rid
# Swift: empty the container before DELETE so the API returns 204
# (409 Conflict for a non-empty container is correct OpenStack behaviour).
if (
op.method == "DELETE"
and pack.name == "swift"
and op.resource_type == "container"
):
acct = local.get("account") or project_id
cname = local.get("container") or local.get("id")
if acct and cname:
base = service_base_url(host, "swift", port=pack.port)
st_list, objs = http_request(
"GET",
f"{base}/v1/{acct}/{cname}",
token=token,
service="swift",
)
if st_list in SUCCESS and isinstance(objs, list):
for obj in objs:
name = obj.get("name") if isinstance(obj, dict) else None
if name:
http_request(
"DELETE",
f"{base}/v1/{acct}/{cname}/{name}",
token=token,
service="swift",
)
result, payload = probe_operation(
host, pack, op, token=token, ctx=local, project_id=project_id, mode="lifecycle"
)
@@ -819,6 +965,9 @@ def probe_series_lifecycle(
report.results.append(result)
done.add((op.method, op.path))
_append_synthetic_heads(
report, packs, host=host, token=token, project_id=project_id, ctx=base_ctx
)
return report
@@ -852,6 +1001,27 @@ def probe_series(
host, pack, op, token=token, ctx=ctx, project_id=project_id, mode="probe"
)
report.results.append(result)
if not collections_only:
_append_synthetic_heads(
report, packs, host=host, token=token, project_id=project_id, ctx=ctx
)
else:
# Smoke: HEAD only for the collection GET paths we probed.
for pack in packs.values():
for op in pack.operations:
if op.method != "GET" or "{" in op.path:
continue
result, _ = probe_operation(
host,
pack,
op,
token=token,
ctx=ctx,
project_id=project_id,
mode="probe",
method_override="HEAD",
)
report.results.append(result)
return report