Add OpenStack request-body schemas and nested console PARAM sync.
This commit is contained in:
+2
-2
@@ -38,7 +38,7 @@ def create_lifespan(
|
||||
await database.connect()
|
||||
app.state.database = database
|
||||
if isinstance(database, AsyncpgDatabase):
|
||||
from app.openstack.demo_cloud import DEMO_PROFILE
|
||||
from app.openstack.demo_cloud import is_demo_profile
|
||||
from app.openstack.seed import seed_openstack
|
||||
|
||||
async with database.pool.acquire() as connection:
|
||||
@@ -49,7 +49,7 @@ def create_lifespan(
|
||||
)
|
||||
except Exception:
|
||||
profile = None
|
||||
if profile != DEMO_PROFILE:
|
||||
if not is_demo_profile(profile):
|
||||
await seed_openstack(connection)
|
||||
workers = tuple(factory(database) for factory in worker_factories)
|
||||
worker_tasks = tuple(asyncio.create_task(worker.run()) for worker in workers)
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
+814
-184
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ from app.openstack.contract_loader import (
|
||||
major_for_series,
|
||||
series_for_major,
|
||||
)
|
||||
from app.openstack.request_examples import body_fields_from_example, schema_example
|
||||
|
||||
_PATH_PARAM = re.compile(r"\{([^{}]+)\}")
|
||||
|
||||
@@ -87,25 +88,24 @@ def openstack_method_payload(
|
||||
for name in path_params
|
||||
]
|
||||
body_fields: list[dict[str, Any]] = []
|
||||
if op.method in {"POST", "PUT", "PATCH"} and op.kind in {
|
||||
"collection",
|
||||
"item",
|
||||
"action",
|
||||
"custom",
|
||||
}:
|
||||
key = op.item_key or op.collection_key or "resource"
|
||||
body_fields.append(
|
||||
{
|
||||
"name": key,
|
||||
"type": "object",
|
||||
"description": "Request body envelope",
|
||||
"optional": op.kind == "action",
|
||||
"enum": [],
|
||||
"example": {key: {"name": "example"}}
|
||||
if op.kind != "action"
|
||||
else {op.action_name or "os-start": None},
|
||||
}
|
||||
)
|
||||
body_example: dict[str, Any] = {}
|
||||
if op.method in {"POST", "PUT", "PATCH"}:
|
||||
if op.request_schema:
|
||||
example = schema_example(op.request_schema)
|
||||
body_example = example if isinstance(example, dict) else {}
|
||||
# PARAM drawer: nested leaves from body_example (oVirt-style).
|
||||
body_fields = body_fields_from_example(body_example)
|
||||
else:
|
||||
# Schemas are required for write ops; keep a minimal
|
||||
# envelope only as a last-resort safety net.
|
||||
key = op.item_key or op.collection_key or "resource"
|
||||
if op.kind == "action":
|
||||
action = op.action_name or "os-start"
|
||||
body_example = {action: None}
|
||||
body_fields = body_fields_from_example(body_example)
|
||||
else:
|
||||
body_example = {key: {"name": "example"}}
|
||||
body_fields = body_fields_from_example(body_example)
|
||||
return {
|
||||
"major": major,
|
||||
"series": series,
|
||||
@@ -138,6 +138,7 @@ def openstack_method_payload(
|
||||
if op.method == "GET" and op.kind in {"collection", "detail"}
|
||||
else [],
|
||||
"body_fields": body_fields,
|
||||
"body_example": body_example,
|
||||
"returns": {"type": "object"},
|
||||
"permissions": [],
|
||||
"service": pack.name,
|
||||
@@ -160,6 +161,7 @@ def openstack_series_majors(runtime_version: str | None = None) -> dict[str, obj
|
||||
"artifact_url": f"contracts/openstack/{item['series']}",
|
||||
"bundled": True,
|
||||
"operation_count": item["operation_count"],
|
||||
"microversions": item.get("microversions") or [],
|
||||
}
|
||||
for item in sorted(series, key=lambda row: row["major"])
|
||||
],
|
||||
|
||||
+26
-4
@@ -291,20 +291,42 @@ async def ui_openstack_microversions(request: Request) -> JSONResponse:
|
||||
|
||||
@router.post("/ui/api/demo/load", include_in_schema=False)
|
||||
async def ui_demo_load(request: Request) -> JSONResponse:
|
||||
"""Load synthetic OpenStack cloud (~1000 servers + full topology)."""
|
||||
"""Load synthetic OpenStack cloud (size: small | large | big)."""
|
||||
|
||||
from app.openstack.demo_cloud import DEMO_SIZE_DEFAULT, resolve_demo_size
|
||||
|
||||
size = DEMO_SIZE_DEFAULT
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
payload = {}
|
||||
if isinstance(payload, dict) and payload.get("size"):
|
||||
size = str(payload["size"])
|
||||
elif request.query_params.get("size"):
|
||||
size = str(request.query_params.get("size"))
|
||||
try:
|
||||
resolve_demo_size(size)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
|
||||
pool = _database_pool(request)
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
async with connection.transaction():
|
||||
result = await seed_openstack_demo(connection)
|
||||
result = await seed_openstack_demo(connection, size=size)
|
||||
summary = await openstack_demo_summary(connection)
|
||||
except Exception as error:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"failed to load OpenStack demo cloud: {error}"
|
||||
) from error
|
||||
return JSONResponse(
|
||||
{"ok": True, "profile": result["profile"], "summary": summary, "seed": result}
|
||||
{
|
||||
"ok": True,
|
||||
"profile": result["profile"],
|
||||
"size": result.get("size"),
|
||||
"summary": summary,
|
||||
"seed": result,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -323,7 +345,7 @@ async def ui_demo_unload(request: Request) -> JSONResponse:
|
||||
summary = await openstack_demo_summary(connection)
|
||||
except Exception as error:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"failed to remove demo data: {error}"
|
||||
status_code=500, detail=f"failed to reset to minimal cluster: {error}"
|
||||
) from error
|
||||
return JSONResponse(
|
||||
{"ok": True, "profile": result.get("profile", "minimal"), "summary": summary}
|
||||
|
||||
Reference in New Issue
Block a user