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
+55 -4
View File
@@ -12,9 +12,13 @@ PUSH_LATEST ?= 1
COMPOSE_RELEASE ?= $(COMPOSE) -f docker-compose.release.yml COMPOSE_RELEASE ?= $(COMPOSE) -f docker-compose.release.yml
HELM_CHART ?= ./helm/openstack-api-simulator HELM_CHART ?= ./helm/openstack-api-simulator
OVERRIDE_EXAMPLE ?= docker-compose.override.example.yml
OVERRIDE_FILE ?= docker-compose.override.yml
COMPOSE_LOCAL ?= $(COMPOSE) -f docker-compose.yml -f $(OVERRIDE_FILE)
.PHONY: help install format lint typecheck test test-unit test-integration test-contract test-compatibility test-surface evidence coverage run dev up down restart logs docker-build docker-up docker-down docker-logs docker-restart db-up db-down db-migrate db-reset api-import api-diff seed seed-demo smoke clean ci ci-all shell release release-build release-up release-down release-seed helm-deps helm-template \ .PHONY: help install format lint typecheck test test-unit test-integration test-contract test-compatibility test-surface evidence coverage run dev up up-local down down-local restart logs docker-build docker-up docker-down docker-logs docker-restart db-up db-down db-migrate db-reset api-import api-diff seed seed-demo smoke clean ci ci-all shell push release release-build release-up release-down release-seed helm-deps helm-template \
test-pulumi-smoke test-pulumi pulumi-tests test-smoke-all-lab test-all-lab clean-test-resources test-pulumi-smoke test-pulumi pulumi-tests test-smoke-all-lab test-all-lab clean-test-resources \
request-bodies-generate request-bodies-import request-bodies-coverage
help: ## Show available commands help: ## Show available commands
@awk 'BEGIN {FS = ":.*## "}; /^[a-zA-Z0-9_-]+:.*## / {printf "%-22s %s\n", $$1, $$2}' $(MAKEFILE_LIST) @awk 'BEGIN {FS = ":.*## "}; /^[a-zA-Z0-9_-]+:.*## / {printf "%-22s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
@@ -72,9 +76,27 @@ up: ## Start PostgreSQL, simulator, and TLS gateway
@test -f .env || cp .env.example .env @test -f .env || cp .env.example .env
$(COMPOSE) up -d --build --wait $(COMPOSE) up -d --build --wait
up-local: ## Start stack with gitignored port overrides (Keystone/console on :15000)
@test -f .env || cp .env.example .env
@if [ ! -f "$(OVERRIDE_FILE)" ]; then \
cp "$(OVERRIDE_EXAMPLE)" "$(OVERRIDE_FILE)"; \
echo "Created $(OVERRIDE_FILE) from $(OVERRIDE_EXAMPLE) (gitignored)."; \
else \
echo "Using existing $(OVERRIDE_FILE) (gitignored)."; \
fi
$(COMPOSE_LOCAL) up -d --build --wait
@echo "Local console: http://127.0.0.1:15000/"
down: ## Stop local services down: ## Stop local services
$(COMPOSE) down $(COMPOSE) down
down-local: ## Stop stack started with make up-local
@if [ -f "$(OVERRIDE_FILE)" ]; then \
$(COMPOSE_LOCAL) down; \
else \
$(COMPOSE) -f docker-compose.yml -f $(OVERRIDE_EXAMPLE) down; \
fi
restart: ## Rebuild and restart the stack restart: ## Rebuild and restart the stack
@test -f .env || cp .env.example .env @test -f .env || cp .env.example .env
$(COMPOSE) up -d --build --force-recreate --wait $(COMPOSE) up -d --build --force-recreate --wait
@@ -122,13 +144,22 @@ api-import: ## Import an API snapshot
api-diff: ## Compare API snapshots api-diff: ## Compare API snapshots
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) openstack-api-contract diff $(ARGS) $(COMPOSE) run --rm --no-deps $(SERVICE_DEV) openstack-api-contract diff $(ARGS)
request-bodies-generate: ## Regenerate contracts/openstack/request_bodies from api-ref catalog
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) python tools/os_api_inventory/generate_request_bodies.py
request-bodies-import: ## Merge Tier-1 request schemas from gtema/openstack-openapi
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) sh -c 'pip install -q pyyaml && python tools/os_api_inventory/import_openapi_bodies.py'
request-bodies-coverage: ## Fail if any write op lacks a request_schema
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) python tools/os_api_inventory/generate_request_bodies.py --coverage
seed: ## Seed minimal OpenStack lab data seed: ## Seed minimal OpenStack lab data
@test -f .env || cp .env.example .env @test -f .env || cp .env.example .env
SEED_PROFILE="$${PROFILE:-minimal}" $(COMPOSE) run --rm --entrypoint python $(SERVICE_SIM) -m app.openstack.seed_cli --profile "$${PROFILE:-minimal}" SEED_PROFILE="$${PROFILE:-minimal}" $(COMPOSE) run --rm --entrypoint python $(SERVICE_SIM) -m app.openstack.seed_cli --profile "$${PROFILE:-minimal}"
seed-demo: ## Seed full OpenStack demo cloud (~1000 servers) seed-demo: ## Seed demo cloud (SIZE=small|large|big, default large = 1000 VMs)
@test -f .env || cp .env.example .env @test -f .env || cp .env.example .env
$(COMPOSE) run --rm --entrypoint python $(SERVICE_SIM) -m app.openstack.seed_cli --profile demo $(COMPOSE) run --rm --entrypoint python $(SERVICE_SIM) -m app.openstack.seed_cli --profile "demo-$${SIZE:-large}"
smoke: ## Keystone → multi-service OpenStack smoke smoke: ## Keystone → multi-service OpenStack smoke
@test -f .env || cp .env.example .env @test -f .env || cp .env.example .env
@@ -138,6 +169,26 @@ smoke: ## Keystone → multi-service OpenStack smoke
shell: ## Open an interactive shell in the development container shell: ## Open an interactive shell in the development container
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) bash $(COMPOSE) run --rm --no-deps $(SERVICE_DEV) bash
push: ## git add ., ask for commit message, push to origin + antropoff
@git add .
@if git diff --cached --quiet; then \
echo "Nothing to commit (working tree clean after git add .)."; \
else \
if [ -n "$(MSG)" ]; then \
msg="$(MSG)"; \
else \
printf "Commit message: "; \
IFS= read -r msg < /dev/tty; \
fi; \
if [ -z "$$msg" ]; then \
echo "Empty commit message; aborting." >&2; \
exit 1; \
fi; \
git commit -m "$$msg"; \
fi
git push origin HEAD
git push antropoff HEAD
clean: ## Remove generated local artifacts clean: ## Remove generated local artifacts
rm -rf .coverage coverage.xml htmlcov .mypy_cache .pytest_cache .ruff_cache rm -rf .coverage coverage.xml htmlcov .mypy_cache .pytest_cache .ruff_cache
+2 -2
View File
@@ -38,7 +38,7 @@ def create_lifespan(
await database.connect() await database.connect()
app.state.database = database app.state.database = database
if isinstance(database, AsyncpgDatabase): 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 from app.openstack.seed import seed_openstack
async with database.pool.acquire() as connection: async with database.pool.acquire() as connection:
@@ -49,7 +49,7 @@ def create_lifespan(
) )
except Exception: except Exception:
profile = None profile = None
if profile != DEMO_PROFILE: if not is_demo_profile(profile):
await seed_openstack(connection) await seed_openstack(connection)
workers = tuple(factory(database) for factory in worker_factories) workers = tuple(factory(database) for factory in worker_factories)
worker_tasks = tuple(asyncio.create_task(worker.run()) for worker in workers) worker_tasks = tuple(asyncio.create_task(worker.run()) for worker in workers)
+18
View File
@@ -9,6 +9,7 @@ from pathlib import Path
from typing import Any from typing import Any
from app.openstack.opspec import OperationSpec, SeriesManifest, ServicePack 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" _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(): if not man.is_file():
continue continue
data = json.loads(man.read_text()) 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( result.append(
{ {
"series": data.get("series", path.name), "series": data.get("series", path.name),
@@ -51,6 +63,7 @@ def list_series() -> list[dict[str, Any]]:
"service_count": data.get("service_count", 0), "service_count": data.get("service_count", 0),
"checksum": data.get("checksum", ""), "checksum": data.get("checksum", ""),
"generated_at": data.get("generated_at", ""), "generated_at": data.get("generated_at", ""),
"microversions": microversions,
} }
) )
return result return result
@@ -92,6 +105,7 @@ def load_series_pack(series: str) -> dict[str, ServicePack]:
data = json.loads(api.read_text()) data = json.loads(api.read_text())
name = str(data["service"]) name = str(data["service"])
ops = [_op_from_dict(name, raw) for raw in data.get("operations") or []] ops = [_op_from_dict(name, raw) for raw in data.get("operations") or []]
ops = attach_request_schemas(name, ops)
packs[name] = ServicePack( packs[name] = ServicePack(
name=name, name=name,
typ=str(data.get("type") or name), typ=str(data.get("type") or name),
@@ -129,9 +143,13 @@ class ContractRuntime:
def reload(self, series: str | None = None) -> dict[str, Any]: def reload(self, series: str | None = None) -> dict[str, Any]:
with self._lock: with self._lock:
clear_request_body_cache()
target = (series or self.series).lower() target = (series or self.series).lower()
series_changed = target != self.series
self.packs = load_series_pack(target) self.packs = load_series_pack(target)
self.series = target self.series = target
if series_changed:
self.microversion_overrides.clear()
man = load_manifest(target) man = load_manifest(target)
return { return {
"series": man.series, "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 from __future__ import annotations
import json import json
from dataclasses import asdict, dataclass
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID
@@ -12,14 +13,108 @@ from app.openstack.ids import oid
from app.security.auth import hash_secret from app.security.auth import hash_secret
DEMO_PROFILE = "openstack-demo-cloud" DEMO_PROFILE = "openstack-demo-cloud"
DEMO_SIZE_DEFAULT = "large"
DEMO_SERVER_COUNT = 1000
DEMO_VOLUME_COUNT = 600 @dataclass(frozen=True, slots=True)
DEMO_HYPERVISOR_COUNT = 16 class DemoClusterSize:
DEMO_IRONIC_COUNT = 24 """Proportional inventory for a demo cluster size button."""
DEMO_LB_COUNT = 12
DEMO_STACK_COUNT = 30 name: str
DEMO_FIP_COUNT = 120 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") 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.""" """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) await clear_openstack_state(conn)
pw = hash_secret(password, salt=b"openstack-sim-v1") 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}', '{"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)]}" host = f"compute-{(i // len(AZS)) + 1:02d}.{AZS[i % len(AZS)]}"
az = 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( await conn.execute(
"""INSERT INTO os_hypervisors( """INSERT INTO os_hypervisors(
id, hypervisor_hostname, state, status, host_ip, vcpus, vcpus_used, 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): for i, az in enumerate(AZS):
hosts = [ hosts = [
f"compute-{(j // len(AZS)) + 1:02d}.{az}" 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( await conn.execute(
"""INSERT INTO os_aggregates(id, name, availability_zone, hosts, metadata) """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, 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(): for pname, pid in project_ids.items():
base = cidr_base[pname] 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}") net_id = oid(f"net:{pname}-{suffix}")
subnet_id = oid(f"subnet:{pname}-{suffix}") subnet_id = oid(f"subnet:{pname}-{suffix}")
await conn.execute( 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}.0/24",
f"10.{base}.{extra_i * 10}.1", f"10.{base}.{extra_i * 10}.1",
) )
for sg_name, desc in ( for sg_name, desc_prefix, port in sg_tiers[:extra_security_groups]:
("web", f"HTTP/S for {pname}"),
("db", f"Database tier for {pname}"),
("cache", f"Cache tier for {pname}"),
):
sg_id = oid(f"sg:{pname}-{sg_name}") sg_id = oid(f"sg:{pname}-{sg_name}")
await conn.execute( await conn.execute(
"""INSERT INTO os_security_groups(id, project_id, name, description) """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, sg_id,
pid, pid,
sg_name, sg_name,
desc, f"{desc_prefix} for {pname}",
) )
await conn.execute( await conn.execute(
"""INSERT INTO os_security_group_rules( """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}"), oid(f"sgrule:{pname}:{sg_name}"),
sg_id, sg_id,
pid, pid,
443 if sg_name == "web" else (5432 if sg_name == "db" else 6379), port,
443 if sg_name == "web" else (5432 if sg_name == "db" else 6379), 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 uname, uid in user_ids.items():
for suffix in ("key", "deploy", "ci", "bastion"): for suffix in keypair_suffixes[:keypairs_per_user]:
await conn.execute( await conn.execute(
"""INSERT INTO os_keypairs(name, user_id, fingerprint, public_key, type) """INSERT INTO os_keypairs(name, user_id, fingerprint, public_key, type)
VALUES($1,$2,$3,$4,'ssh')""", 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") tenant_cycle = ("admin", "demo", "production", "staging", "development", "demo")
hypervisor_names = [ hypervisor_names = [
f"compute-{(i // len(AZS)) + 1:02d}.{AZS[i % len(AZS)]}" 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 = [] server_rows = []
port_rows = [] port_rows = []
for i in range(DEMO_SERVER_COUNT): for i in range(server_count):
pname = tenant_cycle[i % len(tenant_cycle)] pname = tenant_cycle[i % len(tenant_cycle)]
pid = project_ids[pname] pid = project_ids[pname]
net_id, _subnet = project_nets[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)""", VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb)""",
port_rows, 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 # Volumes
volume_rows = [] volume_rows = []
for i in range(DEMO_VOLUME_COUNT): for i in range(volume_count):
pname = tenant_cycle[i % len(tenant_cycle)] pname = tenant_cycle[i % len(tenant_cycle)]
volume_rows.append( volume_rows.append(
( (
oid(f"volume:demo:{i}"), oid(f"volume:demo:{i}"),
project_ids[pname], project_ids[pname],
f"vol-{pname}-{i:04d}", 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, (i % 5 + 1) * 10,
"lvmdriver-1" if i % 3 else "ceph", "lvmdriver-1" if i % 3 else "ceph",
i % 11 == 0, 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. # Per-server nested rows so any listed server has DB-backed attachments/allocations.
attachment_rows = [] attachment_rows = []
for i in range(DEMO_SERVER_COUNT): for i in range(server_count):
pname = tenant_cycle[i % len(tenant_cycle)] pname = tenant_cycle[i % len(tenant_cycle)]
sid = str(oid(f"server:demo:{i}")) 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}")) port_id = str(oid(f"port:demo:{i}"))
pid = project_ids[pname] pid = project_ids[pname]
attachment_rows.append( attachment_rows.append(
@@ -635,7 +786,7 @@ async def seed_openstack_demo(conn: Connection, *, password: str = "secret") ->
) )
# Floating IPs # Floating IPs
for i in range(DEMO_FIP_COUNT): for i in range(fip_count):
pname = tenant_cycle[i % len(tenant_cycle)] pname = tenant_cycle[i % len(tenant_cycle)]
await conn.execute( await conn.execute(
"""INSERT INTO os_floating_ips( """INSERT INTO os_floating_ips(
@@ -650,7 +801,7 @@ async def seed_openstack_demo(conn: Connection, *, password: str = "secret") ->
) )
# Ironic nodes # Ironic nodes
for i in range(DEMO_IRONIC_COUNT): for i in range(ironic_count):
await conn.execute( await conn.execute(
"""INSERT INTO os_nodes( """INSERT INTO os_nodes(
id, name, driver, provision_state, power_state, resource_class, 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 # Octavia LBs
for i in range(DEMO_LB_COUNT): for i in range(lb_count):
pname = tenant_cycle[i % len(tenant_cycle)] pname = tenant_cycle[i % len(tenant_cycle)]
await conn.execute( await conn.execute(
"""INSERT INTO os_loadbalancers( """INSERT INTO os_loadbalancers(
@@ -686,7 +837,7 @@ async def seed_openstack_demo(conn: Connection, *, password: str = "secret") ->
) )
# Heat stacks # Heat stacks
for i in range(DEMO_STACK_COUNT): for i in range(stack_count):
pname = tenant_cycle[i % len(tenant_cycle)] pname = tenant_cycle[i % len(tenant_cycle)]
await conn.execute( await conn.execute(
"""INSERT INTO os_stacks( """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 # Generic service samples (multiple per service) — keep resource_type aligned
# with pack operation resource_type so schema list endpoints return rows. # with pack operation resource_type so schema list endpoints return rows.
samples = [] samples = []
for i in range(8): for i in range(surface_sample_count):
samples.extend( samples.extend(
[ [
("barbican", "secret", f"secret-{i}", {"secret_type": "passphrase"}), ("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 # Nova server groups (specialized table) — denser in demo project
for i in range(16): for i in range(server_group_count):
pname = "demo" if i < 8 else tenant_cycle[i % len(tenant_cycle)] 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 = [ members = [
str(oid(f"server:demo:{3 + (i % 8) * 6}")), str(oid(f"server:demo:{(3 + (i % span) * 6) % server_count}")),
str(oid(f"server:demo:{3 + ((i + 1) % 8) * 6}")), str(oid(f"server:demo:{(3 + ((i + 1) % span) * 6) % server_count}")),
] ]
await conn.execute( await conn.execute(
"""INSERT INTO os_server_groups(id, project_id, name, policies, members) """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_trunk = str(oid("neutron:trunk:trunk-0"))
demo_local_ip = str(oid("neutron:local_ip:lip-0")) demo_local_ip = str(oid("neutron:local_ip:lip-0"))
demo_bgpvpn = str(oid("neutron:bgpvpn:bgpvpn-0")) demo_bgpvpn = str(oid("neutron:bgpvpn:bgpvpn-0"))
demo_stack_id = str(oid("stack:demo:3")) demo_stack_idx = min(3, max(0, stack_count - 1))
demo_stack_name = "stack-demo-03" 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]]] = [] 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. # Cover the first listed servers (and a wider spread) so nested GETs hit DB rows.
for i in range(24): for i in range(nested_sample_count):
sidx = i sidx = i % server_count
sid = str(oid(f"server:demo:{sidx}")) 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}")) port_id = str(oid(f"port:demo:{sidx}"))
nested_samples.extend( 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: for service, rtype, name, data in nested_samples:
item_id = oid(f"{service}:{rtype}:{name}") item_id = oid(f"{service}:{rtype}:{name}")
payload = {"id": str(item_id), "name": name, "status": data.get("status", "ACTIVE"), **data} 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( await conn.execute(
"""INSERT INTO os_api_objects(id, service, resource_type, project_id, name, status, data) """INSERT INTO os_api_objects(id, service, resource_type, project_id, name, status, data)
VALUES($1,$2,$3,$4,$5,$6,$7::jsonb)""", VALUES($1,$2,$3,$4,$5,$6,$7::jsonb)""",
item_id, item_id,
service, service,
rtype, rtype,
None, # shared — nested lists visible for any project token owner_pid,
name, name,
str(payload.get("status") or "ACTIVE"), str(payload.get("status") or "ACTIVE"),
json.dumps(payload), json.dumps(payload),
) )
# Quotas as api objects (name=project id so Neutron-style /quotas/{project_id} resolves) # 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 pname, pid in project_ids.items():
for svc, rtype, data in ( for svc, rtype, data in (
("nova", "quota_set", {"instances": 200, "cores": 800, "ram": 1_024_000}), (
("cinder", "quota_set", {"volumes": 200, "gigabytes": 50_000}), "nova",
("neutron", "quota", {"network": 50, "subnet": 100, "port": 500, "floatingip": 50}), "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( await conn.execute(
"""INSERT INTO os_api_objects(id, service, resource_type, project_id, name, status, data) """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 from app.openstack.seed_discovery import seed_discovery_documents
discovery = await seed_discovery_documents(conn) 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} pack_seed = {**pack_seed, **discovery}
await conn.execute( await conn.execute(
"""INSERT INTO os_demo_meta(key, value) VALUES('profile', $1), ('servers', $2), ('password', $3)""", """INSERT INTO os_demo_meta(key, value)
DEMO_PROFILE, VALUES('profile', $1), ('size', $2), ('servers', $3), ('password', $4)""",
str(DEMO_SERVER_COUNT), profile,
cfg.name,
str(server_count),
password, password,
) )
return { return {
"profile": DEMO_PROFILE, "profile": profile,
"servers": DEMO_SERVER_COUNT, "size": cfg.name,
"servers": server_count,
"pack_seed": pack_seed, "pack_seed": pack_seed,
"volumes": DEMO_VOLUME_COUNT, "volumes": volume_count,
"hypervisors": DEMO_HYPERVISOR_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()), "projects": list(project_ids.keys()),
"users": list(user_ids.keys()), "users": list(user_ids.keys()),
"password": password, "password": password,
"availability_zones": list(AZS), "availability_zones": list(AZS),
"cluster": asdict(cfg),
} }
async def openstack_demo_summary(conn: Connection) -> dict[str, Any]: async def openstack_demo_summary(conn: Connection) -> dict[str, Any]:
profile = await conn.fetchval("SELECT value FROM os_demo_meta WHERE key='profile'") 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") servers = await conn.fetchval("SELECT count(*) FROM os_servers")
volumes = await conn.fetchval("SELECT count(*) FROM os_volumes") volumes = await conn.fetchval("SELECT count(*) FROM os_volumes")
networks = await conn.fetchval("SELECT count(*) FROM os_networks") 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") stacks = await conn.fetchval("SELECT count(*) FROM os_stacks")
nodes = await conn.fetchval("SELECT count(*) FROM os_nodes") nodes = await conn.fetchval("SELECT count(*) FROM os_nodes")
fips = await conn.fetchval("SELECT count(*) FROM os_floating_ips") 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 { return {
"loaded": profile == DEMO_PROFILE, "loaded": loaded,
"profile": profile or "minimal", "profile": profile or "minimal",
"size": (cfg.name if cfg else size) or None,
"servers": int(servers or 0), "servers": int(servers or 0),
"volumes": int(volumes or 0), "volumes": int(volumes or 0),
"networks": int(networks 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), "stacks": int(stacks or 0),
"ironic_nodes": int(nodes or 0), "ironic_nodes": int(nodes or 0),
"floating_ips": int(fips 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.auth import TokenContext
from app.openstack.deps import get_conn, require_project_token, require_token from app.openstack.deps import get_conn, require_project_token, require_token
from app.openstack.errors import OpenStackError from app.openstack.errors import OpenStackError
from app.openstack.singular import singular as _singular
from app.openstack.surface import SERVICES, ServiceSpec from app.openstack.surface import SERVICES, ServiceSpec
_PATH_PARAM = re.compile(r"\{([^{}]+)\}") _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]: def _wrap_list(collection_key: str, items: list[dict[str, Any]]) -> dict[str, Any]:
return {collection_key: items} return {collection_key: items}
+2
View File
@@ -29,6 +29,8 @@ class OperationSpec:
action_name: str | None = None action_name: str | None = None
response_fixture: dict[str, Any] | None = None response_fixture: dict[str, Any] | None = None
notes: str = "" 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]: def path_params(self) -> list[str]:
import re import re
+6 -1
View File
@@ -190,11 +190,16 @@ def register_openstack_contract_routes(
full_path = f"/_os/{pack.name}{path}" full_path = f"/_os/{pack.name}{path}"
name = f"{_ROUTE_NAME_PREFIX}{pack.name}:{op.method}:{op.path}" name = f"{_ROUTE_NAME_PREFIX}{pack.name}:{op.method}:{op.path}"
endpoint = _make_contract_endpoint(pack, op, handlers, dispatch_fn) 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( app.add_api_route(
full_path, full_path,
endpoint, endpoint,
methods=[op.method], methods=methods,
name=name, name=name,
include_in_schema=True, include_in_schema=True,
tags=[service_openapi_tag(pack.name)], 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) size = int((data or {}).get("size") or 0)
else: else:
size = int(row["size"] or 0) 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" content = b"\0" * min(size, 64) if size else b"probe-image"
return Response( return Response(
content=content, content=content,
media_type="application/octet-stream", 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)} 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.put("/v1/{tenant_id}/stacks/{id}")
@router.patch("/v1/{tenant_id}/stacks/{id}") @router.patch("/v1/{tenant_id}/stacks/{id}")
async def update_stack_by_id( async def update_stack_by_id(
@@ -156,23 +202,33 @@ async def update_stack_by_id(
ctx: Annotated[TokenContext, Depends(require_project_token)], ctx: Annotated[TokenContext, Depends(require_project_token)],
) -> dict[str, object]: ) -> dict[str, object]:
_ = tenant_id _ = tenant_id
payload = await request.json() return await _update_stack(
stack = payload.get("stack") or payload project_id=ctx.project_id,
row = await conn.fetchrow( stack_id=None,
"SELECT * FROM os_stacks WHERE project_id=$1 AND (id::text=$2 OR stack_name=$2) ORDER BY created_at DESC LIMIT 1", stack_name=id,
ctx.project_id, request=request,
id, 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"] @router.put("/v1/{tenant_id}/stacks/{stack_name}/{stack_id}")
await conn.execute( @router.patch("/v1/{tenant_id}/stacks/{stack_name}/{stack_id}")
"UPDATE os_stacks SET description=$1, updated_at=now(), stack_status='UPDATE_COMPLETE' WHERE id=$2", async def update_stack_by_name(
desc, tenant_id: str,
row["id"], 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) @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]: 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 { return {
"id": str(row["id"]), "id": str(row["id"]),
"name": row["name"], "name": name,
"status": row["status"], "status": row["status"],
"shared": row["shared"], "shared": row["shared"],
"admin_state_up": row["admin_state_up"], "admin_state_up": row["admin_state_up"],
"tenant_id": str(row["project_id"]), "tenant_id": str(row["project_id"]),
"project_id": str(row["project_id"]), "project_id": str(row["project_id"]),
"router:external": False, "router:external": is_external,
"provider:network_type": "vxlan", "provider:network_type": "flat" if is_external else "vxlan",
"mtu": 1450, "mtu": 1500 if is_external else 1450,
} }
+72 -21
View File
@@ -409,8 +409,8 @@ async def server_action(
"unrescue": "ACTIVE", "unrescue": "ACTIVE",
"os-stop": "SHUTOFF", "os-stop": "SHUTOFF",
"osStop": "SHUTOFF", "osStop": "SHUTOFF",
"shelve": "SHUTOFF", "shelve": "SHELVED",
"shelveOffload": "SHUTOFF", "shelveOffload": "SHELVED_OFFLOADED",
"pause": "PAUSED", "pause": "PAUSED",
"suspend": "SUSPENDED", "suspend": "SUSPENDED",
"rescue": "RESCUE", "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") @router.get("/v2.1/os-aggregates")
async def list_aggregates( async def list_aggregates(
conn: Annotated[Connection, Depends(get_conn)], 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") rows = await conn.fetch("SELECT * FROM os_aggregates ORDER BY id")
except Exception: except Exception:
rows = [] rows = []
return { return {"aggregates": [_aggregate_dict(r) for r in rows]}
"aggregates": [
{
"id": r["id"], @router.get("/v2.1/os-aggregates/{aggregate_id}")
"name": r["name"], async def show_aggregate(
"availability_zone": r["availability_zone"], aggregate_id: str,
"hosts": r["hosts"] if not isinstance(r["hosts"], str) else json.loads(r["hosts"]), conn: Annotated[Connection, Depends(get_conn)],
"metadata": r["metadata"] _ctx: Annotated[TokenContext, Depends(require_project_token)],
if not isinstance(r["metadata"], str) ) -> dict[str, object]:
else json.loads(r["metadata"]), row = await conn.fetchrow(
} """SELECT * FROM os_aggregates
for r in rows 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") @router.get("/v2.1/os-services")
@@ -1137,7 +1153,8 @@ async def instance_actions(
) -> dict[str, object]: ) -> dict[str, object]:
rows = await conn.fetch( rows = await conn.fetch(
"""SELECT id, name, data FROM os_api_objects """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) AND (data->>'server_id'=$2 OR data->>'instance_uuid'=$2)
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 20""", LIMIT 20""",
@@ -1170,7 +1187,8 @@ async def show_instance_action(
) -> dict[str, object]: ) -> dict[str, object]:
row = await conn.fetchrow( row = await conn.fetchrow(
"""SELECT id, name, data FROM os_api_objects """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) AND (id::text=$2 OR data->>'request_id'=$2 OR name=$2)
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 1""", LIMIT 1""",
@@ -1181,7 +1199,8 @@ async def show_instance_action(
# Prefer an existing action for this server; otherwise persist the requested id. # Prefer an existing action for this server; otherwise persist the requested id.
row = await conn.fetchrow( row = await conn.fetchrow(
"""SELECT id, name, data FROM os_api_objects """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) AND (data->>'server_id'=$2 OR data->>'instance_uuid'=$2)
ORDER BY created_at DESC LIMIT 1""", ORDER BY created_at DESC LIMIT 1""",
ctx.project_id, ctx.project_id,
@@ -1243,7 +1262,8 @@ async def _load_server_metadata(
return metadata, public return metadata, public
api = await conn.fetchrow( api = await conn.fetchrow(
"""SELECT data FROM os_api_objects """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) AND (data->>'server_id'=$2 OR id::text=$2)
ORDER BY created_at LIMIT 1""", ORDER BY created_at LIMIT 1""",
project_id, 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") @router.get("/v2.1/servers/{server_id}/topology")
async def server_topology( async def server_topology(
server_id: str, server_id: str,
@@ -1648,7 +1698,8 @@ async def volume_attachments(
) -> dict[str, object]: ) -> dict[str, object]:
rows = await conn.fetch( rows = await conn.fetch(
"""SELECT * FROM os_api_objects """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) AND (data->>'server_id'=$2 OR data->>'serverId'=$2)
ORDER BY created_at""", ORDER BY created_at""",
ctx.project_id, ctx.project_id,
+30 -1
View File
@@ -8,7 +8,6 @@ from uuid import uuid4
from asyncpg import Connection from asyncpg import Connection
from fastapi import APIRouter, Depends, Request, Response from fastapi import APIRouter, Depends, Request, Response
from fastapi.responses import PlainTextResponse
from app.openstack.auth import TokenContext from app.openstack.auth import TokenContext
from app.openstack.deps import get_conn, require_token from app.openstack.deps import get_conn, require_token
@@ -72,6 +71,36 @@ async def create_container(
return Response(status_code=201) 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}") @router.get("/v1/{account}/{container}")
async def list_objects( async def list_objects(
account: str, 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.contract_loader import ensure_loaded, get_runtime
from app.openstack.errors import OpenStackError from app.openstack.errors import OpenStackError
from app.openstack.opspec import OperationSpec, ServicePack from app.openstack.opspec import OperationSpec, ServicePack
from app.openstack.singular import singular as _singular
_PATH_PARAM = re.compile(r"\{([^{}]+)\}") _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: def _fastapi_path(path: str) -> str:
"""Convert {param} to FastAPI {param} (already compatible).""" """Convert {param} to FastAPI {param} (already compatible)."""
return path if path.startswith("/") else f"/{path}" 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) match = re.search(r"/([^/]+)/\{id\}(?:/|$)", path)
if match: if match:
segment = match.group(1) segment = match.group(1)
singular = ( singular = _singular(segment)
segment[:-1] if segment.endswith("s") and not segment.endswith("ss") else segment
)
parent.setdefault(f"{singular}_id", path_params["id"]) parent.setdefault(f"{singular}_id", path_params["id"])
parent.setdefault(singular, path_params["id"]) parent.setdefault(singular, path_params["id"])
parent.setdefault("parent_id", path_params["id"]) parent.setdefault("parent_id", path_params["id"])
+92 -1
View File
@@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
import json
from asyncpg import Connection from asyncpg import Connection
from app.openstack.ids import oid 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"}]}', '{"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"]}', '{"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) await seed_openstack_extras(conn)
@@ -226,12 +244,85 @@ async def seed_openstack_extras(conn: Connection) -> None:
prefix, 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( await conn.execute(
"""INSERT INTO os_routers(id, project_id, name, status, admin_state_up, external_gateway_info) """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"), oid("router:demo"),
demo_project, 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( await conn.execute(
"""INSERT INTO os_nodes(id, name, driver, provision_state, power_state, resource_class, properties, driver_info, ports) """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 asyncio
import json import json
import os import os
import sys
import asyncpg import asyncpg
from app.config import get_settings 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 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()) conn = await asyncpg.connect(settings.database_url.get_secret_value())
try: try:
async with conn.transaction(): async with conn.transaction():
if profile in {"demo", "demo-cloud", "openstack-demo-cloud"}: key = profile.strip().lower()
return await seed_openstack_demo(conn, password=password) if key in {"minimal", "lab"}:
if profile in {"minimal", "lab", "small"}:
await clear_openstack_state(conn) await clear_openstack_state(conn)
return await seed_openstack(conn, password=password) 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: finally:
await conn.close() await conn.close()
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
sizes = ", ".join(sorted(DEMO_CLUSTER_SIZES))
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument( parser.add_argument(
"--profile", "--profile",
default=os.environ.get("SEED_PROFILE", "minimal"), 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")) parser.add_argument("--password", default=os.environ.get("OS_PASSWORD", "secret"))
args = parser.parse_args(argv) 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 collections import defaultdict
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
from urllib.parse import urlparse
from uuid import uuid4 from uuid import uuid4
from app.openstack.contract_loader import load_series_pack from app.openstack.contract_loader import load_series_pack
from app.openstack.opspec import OperationSpec, ServicePack 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"\{([^{}]+)\}") _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. # Lifecycle success for exercised CRUD steps.
SUCCESS = frozenset({200, 201, 202, 204}) 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 @dataclass
class ProbeResult: class ProbeResult:
@@ -123,16 +179,6 @@ def fill_path(template: str, ctx: dict[str, str] | None = None) -> str:
return _PATH_PARAM.sub(repl, template) 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( def _body_for(
op: OperationSpec, op: OperationSpec,
*, *,
@@ -259,27 +305,31 @@ def http_request(
token: str | None = None, token: str | None = None,
service: str | None = None, service: str | None = None,
data: dict[str, Any] | None = None, data: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
timeout: float = 20.0, timeout: float = 20.0,
) -> tuple[int, Any]: ) -> tuple[int, Any]:
body = None if data is None else json.dumps(data).encode() body = None if data is None else json.dumps(data).encode()
headers = {"Accept": "application/json"} hdrs = {"Accept": "application/json"}
if data is not None: if data is not None:
headers["Content-Type"] = "application/json" hdrs["Content-Type"] = "application/json"
if token: if token:
headers["X-Auth-Token"] = token hdrs["X-Auth-Token"] = token
if service: if service:
headers["X-OpenStack-Route-Service"] = service # Fallback when the client cannot reach the real service port.
req = urllib.request.Request(url, data=body, headers=headers, method=method) hdrs["X-OpenStack-Route-Service"] = service
if headers:
hdrs.update(headers)
req = urllib.request.Request(url, data=body, headers=hdrs, method=method)
try: try:
with urllib.request.urlopen(req, timeout=timeout) as res: with urllib.request.urlopen(req, timeout=timeout) as res:
raw = res.read().decode() raw = res.read().decode() if method.upper() != "HEAD" else ""
try: try:
parsed: Any = json.loads(raw) if raw else None parsed: Any = json.loads(raw) if raw else None
except json.JSONDecodeError: except json.JSONDecodeError:
parsed = raw parsed = raw
return res.status, parsed return res.status, parsed
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
raw = exc.read().decode() raw = exc.read().decode() if method.upper() != "HEAD" else ""
try: try:
parsed = json.loads(raw) if raw else None parsed = json.loads(raw) if raw else None
except json.JSONDecodeError: except json.JSONDecodeError:
@@ -434,6 +484,7 @@ def probe_operation(
ctx: dict[str, str] | None = None, ctx: dict[str, str] | None = None,
project_id: str | None = None, project_id: str | None = None,
mode: str = "probe", mode: str = "probe",
method_override: str | None = None,
) -> tuple[ProbeResult, Any]: ) -> tuple[ProbeResult, Any]:
path_ctx = dict(ctx or {}) path_ctx = dict(ctx or {})
if project_id: if project_id:
@@ -442,27 +493,72 @@ def probe_operation(
path_ctx.setdefault("tenant_id", project_id) path_ctx.setdefault("tenant_id", project_id)
path_ctx.setdefault("account", project_id) path_ctx.setdefault("account", project_id)
path = fill_path(op.path, path_ctx) path = fill_path(op.path, path_ctx)
url = f"{host.rstrip('/')}{path}" base = service_base_url(host, pack.name, port=pack.port)
data = _body_for(op, ctx=path_ctx, project_id=project_id) url = f"{base}{path}"
status, payload = http_request(op.method, url, token=token, service=pack.name, data=data) 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 = "" 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: if status not in check:
detail = json.dumps(payload)[:300] if not isinstance(payload, str) else str(payload)[:300] detail = json.dumps(payload)[:300] if not isinstance(payload, str) else str(payload)[:300]
result = ProbeResult( result = ProbeResult(
service=pack.name, service=pack.name,
method=op.method, method=method,
path=op.path, path=op.path,
operation_id=op.operation_id, operation_id=op.operation_id if method != "HEAD" else f"{op.operation_id}__head",
status=status, status=status,
detail=detail, detail=detail,
mode=mode, mode=result_mode,
payload=payload, payload=payload,
collection_key=op.collection_key, collection_key=op.collection_key,
) )
return result, payload 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( def _seed_context(
host: str, host: str,
token: str, token: str,
@@ -490,11 +586,33 @@ def _seed_context(
("ironic", "/v1/nodes", "nodes", "node"), ("ironic", "/v1/nodes", "nodes", "node"),
("ironic", "/v1/drivers", "drivers", "driver"), ("ironic", "/v1/drivers", "drivers", "driver"),
("octavia", "/v2/lbaas/loadbalancers", "loadbalancers", "loadbalancer"), ("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: for service, path, key, alias in seeds:
st, body = http_request("GET", f"{host.rstrip('/')}{path}", token=token, service=service) url = f"{service_base_url(host, service)}{path}"
if st >= 400 or not isinstance(body, dict): 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 continue
ids = _extract_ids(body, key) ids = _extract_ids(body, key)
if ids: 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 account = ctx.get("account") or project_id
container = ctx.get("container") or f"probe-c-{uuid4().hex[:8]}" 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" 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( st, _ = http_request(
"PUT", f"{base}/v1/{account}/{container}", token=token, service="swift", data={} "PUT", f"{base}/v1/{account}/{container}", token=token, service="swift", data={}
) )
@@ -766,6 +884,34 @@ def probe_series_lifecycle(
local["_item_id"] = rid local["_item_id"] = rid
local["id"] = rid local["id"] = rid
local["server_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( result, payload = probe_operation(
host, pack, op, token=token, ctx=local, project_id=project_id, mode="lifecycle" 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) report.results.append(result)
done.add((op.method, op.path)) done.add((op.method, op.path))
_append_synthetic_heads(
report, packs, host=host, token=token, project_id=project_id, ctx=base_ctx
)
return report return report
@@ -852,6 +1001,27 @@ def probe_series(
host, pack, op, token=token, ctx=ctx, project_id=project_id, mode="probe" host, pack, op, token=token, ctx=ctx, project_id=project_id, mode="probe"
) )
report.results.append(result) 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 return report
+814 -184
View File
File diff suppressed because it is too large Load Diff
+21 -19
View File
@@ -12,6 +12,7 @@ from app.openstack.contract_loader import (
major_for_series, major_for_series,
series_for_major, series_for_major,
) )
from app.openstack.request_examples import body_fields_from_example, schema_example
_PATH_PARAM = re.compile(r"\{([^{}]+)\}") _PATH_PARAM = re.compile(r"\{([^{}]+)\}")
@@ -87,25 +88,24 @@ def openstack_method_payload(
for name in path_params for name in path_params
] ]
body_fields: list[dict[str, Any]] = [] body_fields: list[dict[str, Any]] = []
if op.method in {"POST", "PUT", "PATCH"} and op.kind in { body_example: dict[str, Any] = {}
"collection", if op.method in {"POST", "PUT", "PATCH"}:
"item", if op.request_schema:
"action", example = schema_example(op.request_schema)
"custom", body_example = example if isinstance(example, dict) else {}
}: # PARAM drawer: nested leaves from body_example (oVirt-style).
key = op.item_key or op.collection_key or "resource" body_fields = body_fields_from_example(body_example)
body_fields.append( else:
{ # Schemas are required for write ops; keep a minimal
"name": key, # envelope only as a last-resort safety net.
"type": "object", key = op.item_key or op.collection_key or "resource"
"description": "Request body envelope", if op.kind == "action":
"optional": op.kind == "action", action = op.action_name or "os-start"
"enum": [], body_example = {action: None}
"example": {key: {"name": "example"}} body_fields = body_fields_from_example(body_example)
if op.kind != "action" else:
else {op.action_name or "os-start": None}, body_example = {key: {"name": "example"}}
} body_fields = body_fields_from_example(body_example)
)
return { return {
"major": major, "major": major,
"series": series, "series": series,
@@ -138,6 +138,7 @@ def openstack_method_payload(
if op.method == "GET" and op.kind in {"collection", "detail"} if op.method == "GET" and op.kind in {"collection", "detail"}
else [], else [],
"body_fields": body_fields, "body_fields": body_fields,
"body_example": body_example,
"returns": {"type": "object"}, "returns": {"type": "object"},
"permissions": [], "permissions": [],
"service": pack.name, "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']}", "artifact_url": f"contracts/openstack/{item['series']}",
"bundled": True, "bundled": True,
"operation_count": item["operation_count"], "operation_count": item["operation_count"],
"microversions": item.get("microversions") or [],
} }
for item in sorted(series, key=lambda row: row["major"]) for item in sorted(series, key=lambda row: row["major"])
], ],
+26 -4
View File
@@ -291,20 +291,42 @@ async def ui_openstack_microversions(request: Request) -> JSONResponse:
@router.post("/ui/api/demo/load", include_in_schema=False) @router.post("/ui/api/demo/load", include_in_schema=False)
async def ui_demo_load(request: Request) -> JSONResponse: 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) pool = _database_pool(request)
try: try:
async with pool.acquire() as connection: async with pool.acquire() as connection:
async with connection.transaction(): async with connection.transaction():
result = await seed_openstack_demo(connection) result = await seed_openstack_demo(connection, size=size)
summary = await openstack_demo_summary(connection) summary = await openstack_demo_summary(connection)
except Exception as error: except Exception as error:
raise HTTPException( raise HTTPException(
status_code=500, detail=f"failed to load OpenStack demo cloud: {error}" status_code=500, detail=f"failed to load OpenStack demo cloud: {error}"
) from error ) from error
return JSONResponse( 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) summary = await openstack_demo_summary(connection)
except Exception as error: except Exception as error:
raise HTTPException( 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 ) from error
return JSONResponse( return JSONResponse(
{"ok": True, "profile": result.get("profile", "minimal"), "summary": summary} {"ok": True, "profile": result.get("profile", "minimal"), "summary": summary}
@@ -268,7 +268,7 @@
"collection_key": "status", "collection_key": "status",
"create_status": 201, "create_status": 201,
"introduced_in": "yoga", "introduced_in": "yoga",
"item_key": "statu", "item_key": "status",
"kind": "collection", "kind": "collection",
"method": "POST", "method": "POST",
"operation_id": "status_create", "operation_id": "status_create",
@@ -282,7 +282,7 @@
{ {
"collection_key": "status", "collection_key": "status",
"introduced_in": "yoga", "introduced_in": "yoga",
"item_key": "statu", "item_key": "status",
"kind": "item", "kind": "item",
"method": "GET", "method": "GET",
"operation_id": "status_show", "operation_id": "status_show",
@@ -296,7 +296,7 @@
{ {
"collection_key": "status", "collection_key": "status",
"introduced_in": "yoga", "introduced_in": "yoga",
"item_key": "statu", "item_key": "status",
"kind": "item", "kind": "item",
"method": "PUT", "method": "PUT",
"operation_id": "status_update", "operation_id": "status_update",
@@ -310,7 +310,7 @@
{ {
"collection_key": "status", "collection_key": "status",
"introduced_in": "yoga", "introduced_in": "yoga",
"item_key": "statu", "item_key": "status",
"kind": "item", "kind": "item",
"method": "PATCH", "method": "PATCH",
"operation_id": "status_patch", "operation_id": "status_patch",
@@ -268,7 +268,7 @@
"collection_key": "status", "collection_key": "status",
"create_status": 201, "create_status": 201,
"introduced_in": "yoga", "introduced_in": "yoga",
"item_key": "statu", "item_key": "status",
"kind": "collection", "kind": "collection",
"method": "POST", "method": "POST",
"operation_id": "status_create", "operation_id": "status_create",
@@ -282,7 +282,7 @@
{ {
"collection_key": "status", "collection_key": "status",
"introduced_in": "yoga", "introduced_in": "yoga",
"item_key": "statu", "item_key": "status",
"kind": "item", "kind": "item",
"method": "GET", "method": "GET",
"operation_id": "status_show", "operation_id": "status_show",
@@ -296,7 +296,7 @@
{ {
"collection_key": "status", "collection_key": "status",
"introduced_in": "yoga", "introduced_in": "yoga",
"item_key": "statu", "item_key": "status",
"kind": "item", "kind": "item",
"method": "PUT", "method": "PUT",
"operation_id": "status_update", "operation_id": "status_update",
@@ -310,7 +310,7 @@
{ {
"collection_key": "status", "collection_key": "status",
"introduced_in": "yoga", "introduced_in": "yoga",
"item_key": "statu", "item_key": "status",
"kind": "item", "kind": "item",
"method": "PATCH", "method": "PATCH",
"operation_id": "status_patch", "operation_id": "status_patch",
@@ -268,7 +268,7 @@
"collection_key": "status", "collection_key": "status",
"create_status": 201, "create_status": 201,
"introduced_in": "yoga", "introduced_in": "yoga",
"item_key": "statu", "item_key": "status",
"kind": "collection", "kind": "collection",
"method": "POST", "method": "POST",
"operation_id": "status_create", "operation_id": "status_create",
@@ -282,7 +282,7 @@
{ {
"collection_key": "status", "collection_key": "status",
"introduced_in": "yoga", "introduced_in": "yoga",
"item_key": "statu", "item_key": "status",
"kind": "item", "kind": "item",
"method": "GET", "method": "GET",
"operation_id": "status_show", "operation_id": "status_show",
@@ -296,7 +296,7 @@
{ {
"collection_key": "status", "collection_key": "status",
"introduced_in": "yoga", "introduced_in": "yoga",
"item_key": "statu", "item_key": "status",
"kind": "item", "kind": "item",
"method": "PUT", "method": "PUT",
"operation_id": "status_update", "operation_id": "status_update",
@@ -310,7 +310,7 @@
{ {
"collection_key": "status", "collection_key": "status",
"introduced_in": "yoga", "introduced_in": "yoga",
"item_key": "statu", "item_key": "status",
"kind": "item", "kind": "item",
"method": "PATCH", "method": "PATCH",
"operation_id": "status_patch", "operation_id": "status_patch",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,944 @@
{
"by_path": {
"PATCH /floatingips/{id}": {
"description": "",
"properties": {
"floatingip": {
"description": "",
"properties": {
"description": {
"description": "",
"type": "string"
},
"dns_domain": {
"description": "",
"type": "string"
},
"dns_name": {
"description": "",
"type": "string"
},
"fixed_ip_address": {
"description": "Fixed IP",
"type": "string"
},
"floating_ip_address": {
"description": "Floating IP",
"type": "string"
},
"floating_network_id": {
"description": "External network UUID",
"format": "uuid",
"type": "string"
},
"port_id": {
"description": "Port UUID",
"format": "uuid",
"type": "string"
},
"subnet_id": {
"description": "Subnet UUID",
"format": "uuid",
"type": "string"
}
},
"type": "object"
}
},
"required": [
"floatingip"
],
"type": "object"
},
"PATCH /leases/{id}": {
"description": "",
"properties": {
"lease": {
"description": "",
"properties": {
"end_date": {
"description": "",
"example": "2026-01-02 00:00",
"type": "string"
},
"events": {
"description": "",
"items": {
"description": "",
"properties": {},
"type": "object"
},
"type": "array"
},
"name": {
"description": "",
"example": "example",
"type": "string"
},
"reservations": {
"description": "",
"items": {
"description": "",
"properties": {
"hypervisor_properties": {
"description": "",
"type": "string"
},
"max": {
"description": "",
"example": 1,
"type": "integer"
},
"min": {
"description": "",
"example": 1,
"type": "integer"
},
"resource_properties": {
"description": "",
"type": "string"
},
"resource_type": {
"description": "",
"example": "physical:host",
"type": "string"
}
},
"type": "object"
},
"type": "array"
},
"start_date": {
"description": "",
"example": "2026-01-01 00:00",
"type": "string"
}
},
"type": "object"
}
},
"required": [
"lease"
],
"type": "object"
},
"PATCH /os-hosts/{id}": {
"description": "",
"properties": {
"host": {
"description": "",
"properties": {
"extra_capabilities": {
"description": "",
"properties": {},
"type": "object"
},
"name": {
"description": "",
"example": "example",
"type": "string"
},
"reservable": {
"default": true,
"description": "",
"type": "boolean"
}
},
"type": "object"
}
},
"required": [
"host"
],
"type": "object"
},
"POST /floatingips": {
"description": "",
"properties": {
"floatingip": {
"description": "",
"properties": {
"description": {
"description": "",
"type": "string"
},
"dns_domain": {
"description": "",
"type": "string"
},
"dns_name": {
"description": "",
"type": "string"
},
"fixed_ip_address": {
"description": "Fixed IP",
"type": "string"
},
"floating_ip_address": {
"description": "Floating IP",
"type": "string"
},
"floating_network_id": {
"description": "External network UUID",
"format": "uuid",
"type": "string"
},
"port_id": {
"description": "Port UUID",
"format": "uuid",
"type": "string"
},
"subnet_id": {
"description": "Subnet UUID",
"format": "uuid",
"type": "string"
}
},
"required": [
"floating_network_id"
],
"type": "object"
}
},
"required": [
"floatingip"
],
"type": "object"
},
"POST /leases": {
"description": "",
"properties": {
"lease": {
"description": "",
"properties": {
"end_date": {
"description": "",
"example": "2026-01-02 00:00",
"type": "string"
},
"events": {
"description": "",
"items": {
"description": "",
"properties": {},
"type": "object"
},
"type": "array"
},
"name": {
"description": "",
"example": "example",
"type": "string"
},
"reservations": {
"description": "",
"items": {
"description": "",
"properties": {
"hypervisor_properties": {
"description": "",
"type": "string"
},
"max": {
"description": "",
"example": 1,
"type": "integer"
},
"min": {
"description": "",
"example": 1,
"type": "integer"
},
"resource_properties": {
"description": "",
"type": "string"
},
"resource_type": {
"description": "",
"example": "physical:host",
"type": "string"
}
},
"type": "object"
},
"type": "array"
},
"start_date": {
"description": "",
"example": "2026-01-01 00:00",
"type": "string"
}
},
"required": [
"name",
"start_date",
"end_date",
"reservations"
],
"type": "object"
}
},
"required": [
"lease"
],
"type": "object"
},
"POST /os-hosts": {
"description": "",
"properties": {
"host": {
"description": "",
"properties": {
"extra_capabilities": {
"description": "",
"properties": {},
"type": "object"
},
"name": {
"description": "",
"example": "example",
"type": "string"
},
"reservable": {
"default": true,
"description": "",
"type": "boolean"
}
},
"required": [
"name"
],
"type": "object"
}
},
"required": [
"host"
],
"type": "object"
},
"PUT /floatingips/{id}": {
"description": "",
"properties": {
"floatingip": {
"description": "",
"properties": {
"description": {
"description": "",
"type": "string"
},
"dns_domain": {
"description": "",
"type": "string"
},
"dns_name": {
"description": "",
"type": "string"
},
"fixed_ip_address": {
"description": "Fixed IP",
"type": "string"
},
"floating_ip_address": {
"description": "Floating IP",
"type": "string"
},
"floating_network_id": {
"description": "External network UUID",
"format": "uuid",
"type": "string"
},
"port_id": {
"description": "Port UUID",
"format": "uuid",
"type": "string"
},
"subnet_id": {
"description": "Subnet UUID",
"format": "uuid",
"type": "string"
}
},
"type": "object"
}
},
"required": [
"floatingip"
],
"type": "object"
},
"PUT /leases/{id}": {
"description": "",
"properties": {
"lease": {
"description": "",
"properties": {
"end_date": {
"description": "",
"example": "2026-01-02 00:00",
"type": "string"
},
"events": {
"description": "",
"items": {
"description": "",
"properties": {},
"type": "object"
},
"type": "array"
},
"name": {
"description": "",
"example": "example",
"type": "string"
},
"reservations": {
"description": "",
"items": {
"description": "",
"properties": {
"hypervisor_properties": {
"description": "",
"type": "string"
},
"max": {
"description": "",
"example": 1,
"type": "integer"
},
"min": {
"description": "",
"example": 1,
"type": "integer"
},
"resource_properties": {
"description": "",
"type": "string"
},
"resource_type": {
"description": "",
"example": "physical:host",
"type": "string"
}
},
"type": "object"
},
"type": "array"
},
"start_date": {
"description": "",
"example": "2026-01-01 00:00",
"type": "string"
}
},
"type": "object"
}
},
"required": [
"lease"
],
"type": "object"
},
"PUT /os-hosts/{id}": {
"description": "",
"properties": {
"host": {
"description": "",
"properties": {
"extra_capabilities": {
"description": "",
"properties": {},
"type": "object"
},
"name": {
"description": "",
"example": "example",
"type": "string"
},
"reservable": {
"default": true,
"description": "",
"type": "boolean"
}
},
"type": "object"
}
},
"required": [
"host"
],
"type": "object"
}
},
"operations": {
"floatingip_create": {
"description": "",
"properties": {
"floatingip": {
"description": "",
"properties": {
"description": {
"description": "",
"type": "string"
},
"dns_domain": {
"description": "",
"type": "string"
},
"dns_name": {
"description": "",
"type": "string"
},
"fixed_ip_address": {
"description": "Fixed IP",
"type": "string"
},
"floating_ip_address": {
"description": "Floating IP",
"type": "string"
},
"floating_network_id": {
"description": "External network UUID",
"format": "uuid",
"type": "string"
},
"port_id": {
"description": "Port UUID",
"format": "uuid",
"type": "string"
},
"subnet_id": {
"description": "Subnet UUID",
"format": "uuid",
"type": "string"
}
},
"required": [
"floating_network_id"
],
"type": "object"
}
},
"required": [
"floatingip"
],
"type": "object"
},
"floatingip_patch": {
"description": "",
"properties": {
"floatingip": {
"description": "",
"properties": {
"description": {
"description": "",
"type": "string"
},
"dns_domain": {
"description": "",
"type": "string"
},
"dns_name": {
"description": "",
"type": "string"
},
"fixed_ip_address": {
"description": "Fixed IP",
"type": "string"
},
"floating_ip_address": {
"description": "Floating IP",
"type": "string"
},
"floating_network_id": {
"description": "External network UUID",
"format": "uuid",
"type": "string"
},
"port_id": {
"description": "Port UUID",
"format": "uuid",
"type": "string"
},
"subnet_id": {
"description": "Subnet UUID",
"format": "uuid",
"type": "string"
}
},
"type": "object"
}
},
"required": [
"floatingip"
],
"type": "object"
},
"floatingip_update": {
"description": "",
"properties": {
"floatingip": {
"description": "",
"properties": {
"description": {
"description": "",
"type": "string"
},
"dns_domain": {
"description": "",
"type": "string"
},
"dns_name": {
"description": "",
"type": "string"
},
"fixed_ip_address": {
"description": "Fixed IP",
"type": "string"
},
"floating_ip_address": {
"description": "Floating IP",
"type": "string"
},
"floating_network_id": {
"description": "External network UUID",
"format": "uuid",
"type": "string"
},
"port_id": {
"description": "Port UUID",
"format": "uuid",
"type": "string"
},
"subnet_id": {
"description": "Subnet UUID",
"format": "uuid",
"type": "string"
}
},
"type": "object"
}
},
"required": [
"floatingip"
],
"type": "object"
},
"host_create": {
"description": "",
"properties": {
"host": {
"description": "",
"properties": {
"extra_capabilities": {
"description": "",
"properties": {},
"type": "object"
},
"name": {
"description": "",
"example": "example",
"type": "string"
},
"reservable": {
"default": true,
"description": "",
"type": "boolean"
}
},
"required": [
"name"
],
"type": "object"
}
},
"required": [
"host"
],
"type": "object"
},
"host_patch": {
"description": "",
"properties": {
"host": {
"description": "",
"properties": {
"extra_capabilities": {
"description": "",
"properties": {},
"type": "object"
},
"name": {
"description": "",
"example": "example",
"type": "string"
},
"reservable": {
"default": true,
"description": "",
"type": "boolean"
}
},
"type": "object"
}
},
"required": [
"host"
],
"type": "object"
},
"host_update": {
"description": "",
"properties": {
"host": {
"description": "",
"properties": {
"extra_capabilities": {
"description": "",
"properties": {},
"type": "object"
},
"name": {
"description": "",
"example": "example",
"type": "string"
},
"reservable": {
"default": true,
"description": "",
"type": "boolean"
}
},
"type": "object"
}
},
"required": [
"host"
],
"type": "object"
},
"lease_create": {
"description": "",
"properties": {
"lease": {
"description": "",
"properties": {
"end_date": {
"description": "",
"example": "2026-01-02 00:00",
"type": "string"
},
"events": {
"description": "",
"items": {
"description": "",
"properties": {},
"type": "object"
},
"type": "array"
},
"name": {
"description": "",
"example": "example",
"type": "string"
},
"reservations": {
"description": "",
"items": {
"description": "",
"properties": {
"hypervisor_properties": {
"description": "",
"type": "string"
},
"max": {
"description": "",
"example": 1,
"type": "integer"
},
"min": {
"description": "",
"example": 1,
"type": "integer"
},
"resource_properties": {
"description": "",
"type": "string"
},
"resource_type": {
"description": "",
"example": "physical:host",
"type": "string"
}
},
"type": "object"
},
"type": "array"
},
"start_date": {
"description": "",
"example": "2026-01-01 00:00",
"type": "string"
}
},
"required": [
"name",
"start_date",
"end_date",
"reservations"
],
"type": "object"
}
},
"required": [
"lease"
],
"type": "object"
},
"lease_patch": {
"description": "",
"properties": {
"lease": {
"description": "",
"properties": {
"end_date": {
"description": "",
"example": "2026-01-02 00:00",
"type": "string"
},
"events": {
"description": "",
"items": {
"description": "",
"properties": {},
"type": "object"
},
"type": "array"
},
"name": {
"description": "",
"example": "example",
"type": "string"
},
"reservations": {
"description": "",
"items": {
"description": "",
"properties": {
"hypervisor_properties": {
"description": "",
"type": "string"
},
"max": {
"description": "",
"example": 1,
"type": "integer"
},
"min": {
"description": "",
"example": 1,
"type": "integer"
},
"resource_properties": {
"description": "",
"type": "string"
},
"resource_type": {
"description": "",
"example": "physical:host",
"type": "string"
}
},
"type": "object"
},
"type": "array"
},
"start_date": {
"description": "",
"example": "2026-01-01 00:00",
"type": "string"
}
},
"type": "object"
}
},
"required": [
"lease"
],
"type": "object"
},
"lease_update": {
"description": "",
"properties": {
"lease": {
"description": "",
"properties": {
"end_date": {
"description": "",
"example": "2026-01-02 00:00",
"type": "string"
},
"events": {
"description": "",
"items": {
"description": "",
"properties": {},
"type": "object"
},
"type": "array"
},
"name": {
"description": "",
"example": "example",
"type": "string"
},
"reservations": {
"description": "",
"items": {
"description": "",
"properties": {
"hypervisor_properties": {
"description": "",
"type": "string"
},
"max": {
"description": "",
"example": 1,
"type": "integer"
},
"min": {
"description": "",
"example": 1,
"type": "integer"
},
"resource_properties": {
"description": "",
"type": "string"
},
"resource_type": {
"description": "",
"example": "physical:host",
"type": "string"
}
},
"type": "object"
},
"type": "array"
},
"start_date": {
"description": "",
"example": "2026-01-01 00:00",
"type": "string"
}
},
"type": "object"
}
},
"required": [
"lease"
],
"type": "object"
}
},
"service": "blazar",
"source": "generated-from-api-ref-catalog"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,356 @@
{
"by_path": {
"PATCH /stacks/{id}": {
"description": "",
"properties": {
"Parameters": {
"description": "",
"type": "string"
},
"StackName": {
"description": "",
"example": "example",
"type": "string"
},
"TemplateBody": {
"description": "",
"example": "{\"AWSTemplateFormatVersion\":\"2010-09-09\"}",
"type": "string"
},
"TimeoutInMinutes": {
"description": "",
"example": 60,
"type": "integer"
}
},
"required": [
"StackName",
"TemplateBody"
],
"type": "object"
},
"POST /": {
"description": "",
"properties": {
"stack": {
"description": "",
"properties": {
"disable_rollback": {
"default": true,
"description": "Disable rollback",
"type": "boolean"
},
"environment": {
"description": "",
"properties": {},
"type": "object"
},
"files": {
"description": "",
"properties": {},
"type": "object"
},
"parameters": {
"description": "Stack parameters",
"properties": {},
"type": "object"
},
"stack_name": {
"description": "Stack name",
"example": "example",
"type": "string"
},
"tags": {
"description": "",
"items": {
"description": "",
"type": "string"
},
"type": "array"
},
"template": {
"description": "",
"properties": {
"description": {
"description": "",
"type": "string"
},
"heat_template_version": {
"description": "",
"example": "2015-04-30",
"type": "string"
},
"parameters": {
"description": "",
"properties": {},
"type": "object"
},
"resources": {
"description": "",
"properties": {},
"type": "object"
}
},
"required": [
"heat_template_version"
],
"type": "object"
},
"template_url": {
"description": "Template URL",
"format": "uri",
"type": "string"
},
"timeout_mins": {
"description": "Timeout minutes",
"example": 60,
"type": "integer"
}
},
"required": [
"stack_name",
"template"
],
"type": "object"
}
},
"type": "object"
},
"POST /stacks": {
"description": "",
"properties": {
"Parameters": {
"description": "",
"type": "string"
},
"StackName": {
"description": "",
"example": "example",
"type": "string"
},
"TemplateBody": {
"description": "",
"example": "{\"AWSTemplateFormatVersion\":\"2010-09-09\"}",
"type": "string"
},
"TimeoutInMinutes": {
"description": "",
"example": 60,
"type": "integer"
}
},
"required": [
"StackName",
"TemplateBody"
],
"type": "object"
},
"PUT /stacks/{id}": {
"description": "",
"properties": {
"Parameters": {
"description": "",
"type": "string"
},
"StackName": {
"description": "",
"example": "example",
"type": "string"
},
"TemplateBody": {
"description": "",
"example": "{\"AWSTemplateFormatVersion\":\"2010-09-09\"}",
"type": "string"
},
"TimeoutInMinutes": {
"description": "",
"example": 60,
"type": "integer"
}
},
"required": [
"StackName",
"TemplateBody"
],
"type": "object"
}
},
"operations": {
"heat_cfn_query": {
"description": "",
"properties": {
"stack": {
"description": "",
"properties": {
"disable_rollback": {
"default": true,
"description": "Disable rollback",
"type": "boolean"
},
"environment": {
"description": "",
"properties": {},
"type": "object"
},
"files": {
"description": "",
"properties": {},
"type": "object"
},
"parameters": {
"description": "Stack parameters",
"properties": {},
"type": "object"
},
"stack_name": {
"description": "Stack name",
"example": "example",
"type": "string"
},
"tags": {
"description": "",
"items": {
"description": "",
"type": "string"
},
"type": "array"
},
"template": {
"description": "",
"properties": {
"description": {
"description": "",
"type": "string"
},
"heat_template_version": {
"description": "",
"example": "2015-04-30",
"type": "string"
},
"parameters": {
"description": "",
"properties": {},
"type": "object"
},
"resources": {
"description": "",
"properties": {},
"type": "object"
}
},
"required": [
"heat_template_version"
],
"type": "object"
},
"template_url": {
"description": "Template URL",
"format": "uri",
"type": "string"
},
"timeout_mins": {
"description": "Timeout minutes",
"example": 60,
"type": "integer"
}
},
"required": [
"stack_name",
"template"
],
"type": "object"
}
},
"type": "object"
},
"stack_create": {
"description": "",
"properties": {
"Parameters": {
"description": "",
"type": "string"
},
"StackName": {
"description": "",
"example": "example",
"type": "string"
},
"TemplateBody": {
"description": "",
"example": "{\"AWSTemplateFormatVersion\":\"2010-09-09\"}",
"type": "string"
},
"TimeoutInMinutes": {
"description": "",
"example": 60,
"type": "integer"
}
},
"required": [
"StackName",
"TemplateBody"
],
"type": "object"
},
"stack_patch": {
"description": "",
"properties": {
"Parameters": {
"description": "",
"type": "string"
},
"StackName": {
"description": "",
"example": "example",
"type": "string"
},
"TemplateBody": {
"description": "",
"example": "{\"AWSTemplateFormatVersion\":\"2010-09-09\"}",
"type": "string"
},
"TimeoutInMinutes": {
"description": "",
"example": 60,
"type": "integer"
}
},
"required": [
"StackName",
"TemplateBody"
],
"type": "object"
},
"stack_update": {
"description": "",
"properties": {
"Parameters": {
"description": "",
"type": "string"
},
"StackName": {
"description": "",
"example": "example",
"type": "string"
},
"TemplateBody": {
"description": "",
"example": "{\"AWSTemplateFormatVersion\":\"2010-09-09\"}",
"type": "string"
},
"TimeoutInMinutes": {
"description": "",
"example": 60,
"type": "integer"
}
},
"required": [
"StackName",
"TemplateBody"
],
"type": "object"
}
},
"service": "heat-cfn",
"source": "generated-from-api-ref-catalog"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,834 @@
{
"by_path": {
"PATCH /v1/notifications/{id}": {
"description": "",
"properties": {
"notification": {
"description": "",
"properties": {
"generated_time": {
"description": "",
"example": "2026-01-01T00:00:00Z",
"type": "string"
},
"hostname": {
"description": "",
"example": "compute-1",
"type": "string"
},
"payload": {
"description": "",
"properties": {
"event": {
"description": "",
"type": "string"
},
"instance_uuid": {
"description": "",
"format": "uuid",
"type": "string"
},
"vir_domain_event": {
"description": "",
"type": "string"
}
},
"type": "object"
},
"source_host_uuid": {
"description": "",
"format": "uuid",
"type": "string"
},
"type": {
"description": "",
"enum": [
"VM",
"PROCESS",
"COMPUTE_HOST"
],
"example": "VM",
"type": "string"
}
},
"type": "object"
}
},
"required": [
"notification"
],
"type": "object"
},
"PATCH /v1/segments/{id}": {
"description": "",
"properties": {
"segment": {
"description": "",
"properties": {
"description": {
"description": "Optional description",
"example": "",
"type": "string"
},
"name": {
"description": "Human-readable name",
"example": "example",
"type": "string"
},
"recovery_method": {
"description": "",
"enum": [
"auto",
"reserved_host",
"auto_priority",
"rh_priority"
],
"example": "auto",
"type": "string"
},
"service_type": {
"description": "",
"enum": [
"compute"
],
"example": "compute",
"type": "string"
}
},
"type": "object"
}
},
"required": [
"segment"
],
"type": "object"
},
"PATCH /v1/segments/{segment_id}/hosts/{id}": {
"description": "",
"properties": {
"host": {
"description": "",
"properties": {
"extra_capabilities": {
"description": "",
"properties": {},
"type": "object"
},
"name": {
"description": "",
"example": "example",
"type": "string"
},
"reservable": {
"default": true,
"description": "",
"type": "boolean"
}
},
"type": "object"
}
},
"required": [
"host"
],
"type": "object"
},
"POST /v1/notifications": {
"description": "",
"properties": {
"notification": {
"description": "",
"properties": {
"generated_time": {
"description": "",
"example": "2026-01-01T00:00:00Z",
"type": "string"
},
"hostname": {
"description": "",
"example": "compute-1",
"type": "string"
},
"payload": {
"description": "",
"properties": {
"event": {
"description": "",
"type": "string"
},
"instance_uuid": {
"description": "",
"format": "uuid",
"type": "string"
},
"vir_domain_event": {
"description": "",
"type": "string"
}
},
"type": "object"
},
"source_host_uuid": {
"description": "",
"format": "uuid",
"type": "string"
},
"type": {
"description": "",
"enum": [
"VM",
"PROCESS",
"COMPUTE_HOST"
],
"example": "VM",
"type": "string"
}
},
"required": [
"type",
"hostname",
"generated_time",
"payload"
],
"type": "object"
}
},
"required": [
"notification"
],
"type": "object"
},
"POST /v1/segments": {
"description": "",
"properties": {
"segment": {
"description": "",
"properties": {
"description": {
"description": "Optional description",
"example": "",
"type": "string"
},
"name": {
"description": "Human-readable name",
"example": "example",
"type": "string"
},
"recovery_method": {
"description": "",
"enum": [
"auto",
"reserved_host",
"auto_priority",
"rh_priority"
],
"example": "auto",
"type": "string"
},
"service_type": {
"description": "",
"enum": [
"compute"
],
"example": "compute",
"type": "string"
}
},
"required": [
"name",
"recovery_method",
"service_type"
],
"type": "object"
}
},
"required": [
"segment"
],
"type": "object"
},
"POST /v1/segments/{segment_id}/hosts": {
"description": "",
"properties": {
"host": {
"description": "",
"properties": {
"extra_capabilities": {
"description": "",
"properties": {},
"type": "object"
},
"name": {
"description": "",
"example": "example",
"type": "string"
},
"reservable": {
"default": true,
"description": "",
"type": "boolean"
}
},
"required": [
"name"
],
"type": "object"
}
},
"required": [
"host"
],
"type": "object"
},
"PUT /v1/notifications/{id}": {
"description": "",
"properties": {
"notification": {
"description": "",
"properties": {
"generated_time": {
"description": "",
"example": "2026-01-01T00:00:00Z",
"type": "string"
},
"hostname": {
"description": "",
"example": "compute-1",
"type": "string"
},
"payload": {
"description": "",
"properties": {
"event": {
"description": "",
"type": "string"
},
"instance_uuid": {
"description": "",
"format": "uuid",
"type": "string"
},
"vir_domain_event": {
"description": "",
"type": "string"
}
},
"type": "object"
},
"source_host_uuid": {
"description": "",
"format": "uuid",
"type": "string"
},
"type": {
"description": "",
"enum": [
"VM",
"PROCESS",
"COMPUTE_HOST"
],
"example": "VM",
"type": "string"
}
},
"type": "object"
}
},
"required": [
"notification"
],
"type": "object"
},
"PUT /v1/segments/{id}": {
"description": "",
"properties": {
"segment": {
"description": "",
"properties": {
"description": {
"description": "Optional description",
"example": "",
"type": "string"
},
"name": {
"description": "Human-readable name",
"example": "example",
"type": "string"
},
"recovery_method": {
"description": "",
"enum": [
"auto",
"reserved_host",
"auto_priority",
"rh_priority"
],
"example": "auto",
"type": "string"
},
"service_type": {
"description": "",
"enum": [
"compute"
],
"example": "compute",
"type": "string"
}
},
"type": "object"
}
},
"required": [
"segment"
],
"type": "object"
},
"PUT /v1/segments/{segment_id}/hosts/{id}": {
"description": "",
"properties": {
"host": {
"description": "",
"properties": {
"extra_capabilities": {
"description": "",
"properties": {},
"type": "object"
},
"name": {
"description": "",
"example": "example",
"type": "string"
},
"reservable": {
"default": true,
"description": "",
"type": "boolean"
}
},
"type": "object"
}
},
"required": [
"host"
],
"type": "object"
}
},
"operations": {
"host_create": {
"description": "",
"properties": {
"host": {
"description": "",
"properties": {
"extra_capabilities": {
"description": "",
"properties": {},
"type": "object"
},
"name": {
"description": "",
"example": "example",
"type": "string"
},
"reservable": {
"default": true,
"description": "",
"type": "boolean"
}
},
"required": [
"name"
],
"type": "object"
}
},
"required": [
"host"
],
"type": "object"
},
"host_patch": {
"description": "",
"properties": {
"host": {
"description": "",
"properties": {
"extra_capabilities": {
"description": "",
"properties": {},
"type": "object"
},
"name": {
"description": "",
"example": "example",
"type": "string"
},
"reservable": {
"default": true,
"description": "",
"type": "boolean"
}
},
"type": "object"
}
},
"required": [
"host"
],
"type": "object"
},
"host_update": {
"description": "",
"properties": {
"host": {
"description": "",
"properties": {
"extra_capabilities": {
"description": "",
"properties": {},
"type": "object"
},
"name": {
"description": "",
"example": "example",
"type": "string"
},
"reservable": {
"default": true,
"description": "",
"type": "boolean"
}
},
"type": "object"
}
},
"required": [
"host"
],
"type": "object"
},
"notification_create": {
"description": "",
"properties": {
"notification": {
"description": "",
"properties": {
"generated_time": {
"description": "",
"example": "2026-01-01T00:00:00Z",
"type": "string"
},
"hostname": {
"description": "",
"example": "compute-1",
"type": "string"
},
"payload": {
"description": "",
"properties": {
"event": {
"description": "",
"type": "string"
},
"instance_uuid": {
"description": "",
"format": "uuid",
"type": "string"
},
"vir_domain_event": {
"description": "",
"type": "string"
}
},
"type": "object"
},
"source_host_uuid": {
"description": "",
"format": "uuid",
"type": "string"
},
"type": {
"description": "",
"enum": [
"VM",
"PROCESS",
"COMPUTE_HOST"
],
"example": "VM",
"type": "string"
}
},
"required": [
"type",
"hostname",
"generated_time",
"payload"
],
"type": "object"
}
},
"required": [
"notification"
],
"type": "object"
},
"notification_patch": {
"description": "",
"properties": {
"notification": {
"description": "",
"properties": {
"generated_time": {
"description": "",
"example": "2026-01-01T00:00:00Z",
"type": "string"
},
"hostname": {
"description": "",
"example": "compute-1",
"type": "string"
},
"payload": {
"description": "",
"properties": {
"event": {
"description": "",
"type": "string"
},
"instance_uuid": {
"description": "",
"format": "uuid",
"type": "string"
},
"vir_domain_event": {
"description": "",
"type": "string"
}
},
"type": "object"
},
"source_host_uuid": {
"description": "",
"format": "uuid",
"type": "string"
},
"type": {
"description": "",
"enum": [
"VM",
"PROCESS",
"COMPUTE_HOST"
],
"example": "VM",
"type": "string"
}
},
"type": "object"
}
},
"required": [
"notification"
],
"type": "object"
},
"notification_update": {
"description": "",
"properties": {
"notification": {
"description": "",
"properties": {
"generated_time": {
"description": "",
"example": "2026-01-01T00:00:00Z",
"type": "string"
},
"hostname": {
"description": "",
"example": "compute-1",
"type": "string"
},
"payload": {
"description": "",
"properties": {
"event": {
"description": "",
"type": "string"
},
"instance_uuid": {
"description": "",
"format": "uuid",
"type": "string"
},
"vir_domain_event": {
"description": "",
"type": "string"
}
},
"type": "object"
},
"source_host_uuid": {
"description": "",
"format": "uuid",
"type": "string"
},
"type": {
"description": "",
"enum": [
"VM",
"PROCESS",
"COMPUTE_HOST"
],
"example": "VM",
"type": "string"
}
},
"type": "object"
}
},
"required": [
"notification"
],
"type": "object"
},
"segment_create": {
"description": "",
"properties": {
"segment": {
"description": "",
"properties": {
"description": {
"description": "Optional description",
"example": "",
"type": "string"
},
"name": {
"description": "Human-readable name",
"example": "example",
"type": "string"
},
"recovery_method": {
"description": "",
"enum": [
"auto",
"reserved_host",
"auto_priority",
"rh_priority"
],
"example": "auto",
"type": "string"
},
"service_type": {
"description": "",
"enum": [
"compute"
],
"example": "compute",
"type": "string"
}
},
"required": [
"name",
"recovery_method",
"service_type"
],
"type": "object"
}
},
"required": [
"segment"
],
"type": "object"
},
"segment_patch": {
"description": "",
"properties": {
"segment": {
"description": "",
"properties": {
"description": {
"description": "Optional description",
"example": "",
"type": "string"
},
"name": {
"description": "Human-readable name",
"example": "example",
"type": "string"
},
"recovery_method": {
"description": "",
"enum": [
"auto",
"reserved_host",
"auto_priority",
"rh_priority"
],
"example": "auto",
"type": "string"
},
"service_type": {
"description": "",
"enum": [
"compute"
],
"example": "compute",
"type": "string"
}
},
"type": "object"
}
},
"required": [
"segment"
],
"type": "object"
},
"segment_update": {
"description": "",
"properties": {
"segment": {
"description": "",
"properties": {
"description": {
"description": "Optional description",
"example": "",
"type": "string"
},
"name": {
"description": "Human-readable name",
"example": "example",
"type": "string"
},
"recovery_method": {
"description": "",
"enum": [
"auto",
"reserved_host",
"auto_priority",
"rh_priority"
],
"example": "auto",
"type": "string"
},
"service_type": {
"description": "",
"enum": [
"compute"
],
"example": "compute",
"type": "string"
}
},
"type": "object"
}
},
"required": [
"segment"
],
"type": "object"
}
},
"service": "masakari",
"source": "generated-from-api-ref-catalog"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -268,7 +268,7 @@
"collection_key": "status", "collection_key": "status",
"create_status": 201, "create_status": 201,
"introduced_in": "yoga", "introduced_in": "yoga",
"item_key": "statu", "item_key": "status",
"kind": "collection", "kind": "collection",
"method": "POST", "method": "POST",
"operation_id": "status_create", "operation_id": "status_create",
@@ -282,7 +282,7 @@
{ {
"collection_key": "status", "collection_key": "status",
"introduced_in": "yoga", "introduced_in": "yoga",
"item_key": "statu", "item_key": "status",
"kind": "item", "kind": "item",
"method": "GET", "method": "GET",
"operation_id": "status_show", "operation_id": "status_show",
@@ -296,7 +296,7 @@
{ {
"collection_key": "status", "collection_key": "status",
"introduced_in": "yoga", "introduced_in": "yoga",
"item_key": "statu", "item_key": "status",
"kind": "item", "kind": "item",
"method": "PUT", "method": "PUT",
"operation_id": "status_update", "operation_id": "status_update",
@@ -310,7 +310,7 @@
{ {
"collection_key": "status", "collection_key": "status",
"introduced_in": "yoga", "introduced_in": "yoga",
"item_key": "statu", "item_key": "status",
"kind": "item", "kind": "item",
"method": "PATCH", "method": "PATCH",
"operation_id": "status_patch", "operation_id": "status_patch",
+41
View File
@@ -0,0 +1,41 @@
# Example local port overrides for `make up-local`.
# Copied to docker-compose.override.yml (gitignored) — not used by CI / make up.
#
# Host 5000 is often taken by macOS AirPlay Receiver; map Keystone/console to 15000.
# Internal container ports stay OpenStack-default.
services:
postgres:
ports: !override
- "127.0.0.1:5433:5432"
api-gateway:
ports: !override
- "127.0.0.1:8080:80"
- "127.0.0.1:8443:443"
- "127.0.0.1:1234:1234"
- "127.0.0.1:15000:5000"
- "127.0.0.1:5050:5050"
- "127.0.0.1:8888:8888"
- "127.0.0.1:9322:9322"
- "127.0.0.1:6385:6385"
- "127.0.0.1:8000:8000"
- "127.0.0.1:8003:8003"
- "127.0.0.1:8004:8004"
- "127.0.0.1:8042:8042"
- "127.0.0.1:18080:8080"
- "127.0.0.1:8774:8774"
- "127.0.0.1:8776:8776"
- "127.0.0.1:8779:8779"
- "127.0.0.1:8786:8786"
- "127.0.0.1:8889:8889"
- "127.0.0.1:8989:8989"
- "127.0.0.1:8999:8999"
- "127.0.0.1:9001:9001"
- "127.0.0.1:9090:9090"
- "127.0.0.1:9292:9292"
- "127.0.0.1:9311:9311"
- "127.0.0.1:9511:9511"
- "127.0.0.1:9517:9517"
- "127.0.0.1:9696:9696"
- "127.0.0.1:9876:9876"
- "127.0.0.1:9890:9890"
- "127.0.0.1:15868:15868"
+1 -1
View File
@@ -33,7 +33,7 @@ curl -X POST http://127.0.0.1:5000/ui/api/openstack/contracts/activate \
-d '{"series":"dalmatian"}' -d '{"series":"dalmatian"}'
``` ```
Or Web UI → Environment → OpenStack API pack → Activate. Or Web UI → API catalog → select series → **Apply as runtime**.
Hot-swap remounts schema routes (`remount_schema_services`) without rebuilding Hot-swap remounts schema routes (`remount_schema_services`) without rebuilding
the image. the image.
+1 -1
View File
@@ -19,7 +19,7 @@ Generated from `contracts/openstack/dalmatian/manifest.json`.
| Yoga | 6 | 1060 | | Yoga | 6 | 1060 |
Older series omit paths introduced later (`tools/os_api_inventory/series_deltas.py`) Older series omit paths introduced later (`tools/os_api_inventory/series_deltas.py`)
and use lower microversion ceilings. Apply a pack in the Environment drawer to hot-swap. and use lower microversion ceilings. Apply a pack in the API catalog drawer to hot-swap.
Surface-complete means every operation in the pack is mounted by the schema engine Surface-complete means every operation in the pack is mounted by the schema engine
(specialized routers still win on overlapping stateful paths). (specialized routers still win on overlapping stateful paths).
+1 -4
View File
@@ -53,7 +53,4 @@ Specialized routers (Keystone, Nova, Neutron, …) remain stateful for happy-pat
## Web UI overrides ## Web UI overrides
Environment drawer → **OpenStack API pack**: API catalog drawer: select series card → microversion on the card → **Apply as runtime**.
- Activate series (hot remount)
- Per-service microversion override
+1 -1
View File
@@ -23,4 +23,4 @@ make seed-demo
## Microversion rejected ## Microversion rejected
Lower the requested compute microversion or clear Web UI overrides. Lower the requested compute microversion or apply an earlier series in the API catalog.
+6 -2
View File
@@ -95,8 +95,12 @@ curl -sH "X-Auth-Token: $TOKEN" http://127.0.0.1:9696/v2.0/networks
## 6. Open the Web UI ## 6. Open the Web UI
[http://localhost:5000/](http://localhost:5000/) — console, Environment drawer [http://localhost:5000/](http://localhost:5000/) — console, API catalog
(OpenStack pack series + microversions), Data drawer (load/unload demo cloud). (OpenStack pack series), Data drawer (load/unload demo cloud).
![Web console](images/web-ui/console-main.png)
Walkthrough of drawers and request parameters: [Web UI](web-ui.md).
## 7. Smoke / conformance ## 7. Smoke / conformance
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

+15
View File
@@ -69,6 +69,21 @@ PYTHONPATH=tools python3 tools/os_api_inventory/generate_packs.py
PYTHONPATH=tools python3 tools/os_api_inventory/coverage_report.py PYTHONPATH=tools python3 tools/os_api_inventory/coverage_report.py
``` ```
## Request body schemas (console)
Write methods (`POST`/`PUT`/`PATCH`) expose full JSON Schema field lists in the
web console. Schemas live in `contracts/openstack/request_bodies/<service>.json`
and are merged onto pack operations at load time (shared across series).
```bash
make request-bodies-generate # rebuild from in-repo api-ref catalog
make request-bodies-import # overlay Tier-1 services from openstack-openapi
make request-bodies-coverage # assert every write op has a schema
```
Tier-1 OpenAPI import covers nova, neutron, keystone, glance, cinder, octavia,
swift, and placement. Other services use the curated catalog generator.
## Backing up lab state ## Backing up lab state
PostgreSQL is the system of record. Use `pg_dump` / volume snapshots. PostgreSQL is the system of record. Use `pg_dump` / volume snapshots.
+1 -1
View File
@@ -33,7 +33,7 @@ curl -X POST http://127.0.0.1:5000/ui/api/openstack/contracts/activate \
-d '{"series":"dalmatian"}' -d '{"series":"dalmatian"}'
``` ```
Или Web UI → Environment → OpenStack API pack → Activate. Или Web UI → API catalog → выбрать серию → **Apply as runtime**.
Hot-swap перемонтирует schema-маршруты (`remount_schema_services`) без пересборки Hot-swap перемонтирует schema-маршруты (`remount_schema_services`) без пересборки
образа. образа.
+1 -1
View File
@@ -19,7 +19,7 @@
| Yoga | 6 | 1060 | | Yoga | 6 | 1060 |
Более старые серии опускают пути, добавленные позже (`tools/os_api_inventory/series_deltas.py`), Более старые серии опускают пути, добавленные позже (`tools/os_api_inventory/series_deltas.py`),
и используют более низкие потолки microversion. Примените пакет в Environment drawer для hot-swap. и используют более низкие потолки microversion. Примените пакет в API catalog drawer для hot-swap.
Surface-complete означает, что каждая операция пакета смонтирована schema-движком Surface-complete означает, что каждая операция пакета смонтирована schema-движком
(специализированные роутеры по-прежнему выигрывают на пересекающихся stateful-путях). (специализированные роутеры по-прежнему выигрывают на пересекающихся stateful-путях).
+1 -4
View File
@@ -53,7 +53,4 @@
## Переопределения Web UI ## Переопределения Web UI
Environment drawer → **OpenStack API pack**: API catalog drawer: карточка серии → microversion в карточке → **Apply as runtime**.
- Активация серии (hot remount)
- Переопределение microversion по сервисам
+1 -1
View File
@@ -24,4 +24,4 @@ make seed-demo
## Microversion отклонён ## Microversion отклонён
Понизьте запрошенную compute microversion или сбросьте переопределения в Web UI. Понизьте запрошенную compute microversion или примените более раннюю серию в API catalog.
+6 -2
View File
@@ -95,8 +95,12 @@ curl -sH "X-Auth-Token: $TOKEN" http://127.0.0.1:9696/v2.0/networks
## 6. Откройте Web UI ## 6. Откройте Web UI
[http://localhost:5000/](http://localhost:5000/) — консоль, Environment drawer [http://localhost:5000/](http://localhost:5000/) — консоль, API catalog
(серия OpenStack pack + microversions), Data drawer (загрузка/выгрузка demo cloud). (серия OpenStack pack), Data drawer (загрузка/выгрузка demo cloud).
![Web-консоль](../images/web-ui/console-main.png)
Обзор drawers и параметров запроса: [Web UI](web-ui.md).
## 7. Smoke / conformance ## 7. Smoke / conformance
+15
View File
@@ -69,6 +69,21 @@ PYTHONPATH=tools python3 tools/os_api_inventory/generate_packs.py
PYTHONPATH=tools python3 tools/os_api_inventory/coverage_report.py PYTHONPATH=tools python3 tools/os_api_inventory/coverage_report.py
``` ```
## Схемы Request body (консоль)
Для `POST`/`PUT`/`PATCH` консоль показывает полные JSON Schema поля.
Схемы лежат в `contracts/openstack/request_bodies/<service>.json` и
подмешиваются к операциям пака при загрузке (общие для всех серий).
```bash
make request-bodies-generate # пересобрать из api-ref каталога
make request-bodies-import # наложить Tier-1 из openstack-openapi
make request-bodies-coverage # проверить, что у всех write-op есть схема
```
Tier-1: nova, neutron, keystone, glance, cinder, octavia, swift, placement.
Остальные сервисы — курируемый генератор каталога.
## Резервное копирование состояния лаборатории ## Резервное копирование состояния лаборатории
PostgreSQL — источник истины. Используйте `pg_dump` / снимки volume. PostgreSQL — источник истины. Используйте `pg_dump` / снимки volume.
+59 -4
View File
@@ -10,15 +10,70 @@
| `/docs` | OpenAPI (simulator) | | `/docs` | OpenAPI (simulator) |
| `/ui/api/…` | UI JSON APIs | | `/ui/api/…` | UI JSON APIs |
![Главный экран консоли](../images/web-ui/console-main.png)
## Endpoints drawer
Обзор поверхности пака по сервисам (например Adjutant). У каждого path —
поддерживаемые HTTP-методы.
![Endpoints drawer](../images/web-ui/endpoints.png)
## Отправка запросов
Выберите verb + path и нажмите **Send**. Успешные ответы попадают в
**RESPONSE**.
![Запрос и ответ](../images/web-ui/request-response.png)
### Request parameters
Для `POST` / `PUT` / `PATCH` в **Request parameters** показаны поля схемы
(dotted-имена для вложенных OpenStack envelope), типы, optional и примеры.
JSON Request body собирается из этих полей.
![Request parameters](../images/web-ui/request-parameters.png)
## Authentication drawer
Вход через лабораторный Keystone (`admin` / `secret`, проекты вроде `admin`
или `demo`). Можно вставить готовый `X-Auth-Token`.
![Authentication drawer](../images/web-ui/authentication.png)
## Environment drawer ## Environment drawer
- **OpenStack API pack** — список серий, активация пакета, переопределения microversion - Runtime / catalog / активная **microversion**, плюс живой инвентарь облака
- Apply немедленно перемонтирует schema-маршруты (servers, nets, volumes…)
![Environment drawer](../images/web-ui/environment.png)
## API catalog drawer
- Выбор карточки серии (`os · yoga` …), microversion в карточке, затем
**Apply as runtime** (выбор сохраняется после перезагрузки)
![API catalog drawer](../images/web-ui/api-catalog.png)
## Data drawer ## Data drawer
- **Load demo cloud**`POST /ui/api/demo/load``seed_openstack_demo` - **Load demo cloud**кластеры small / large / big
- **Unload / minimal**сброс к minimal seed - **Reset to minimal**минимальный lab seed
![Data drawer](../images/web-ui/data.png)
## History drawer
Недавние вызовы консоли: method, URL и status.
![History drawer](../images/web-ui/history.png)
## Help · Compatibility
Покрытие поверхности пака для активной серии (declared / implemented,
сервисы, смесь verb).
![Help compatibility](../images/web-ui/help-compatibility.png)
## Брендинг ## Брендинг
+59 -4
View File
@@ -10,15 +10,70 @@ Console is served from the Keystone/UI port (**5000** on the gateway).
| `/docs` | OpenAPI (simulator) | | `/docs` | OpenAPI (simulator) |
| `/ui/api/…` | UI JSON APIs | | `/ui/api/…` | UI JSON APIs |
![Console main view](images/web-ui/console-main.png)
## Endpoints drawer
Browse the pack surface by service (for example Adjutant). Each path shows the
supported verbs.
![Endpoints drawer](images/web-ui/endpoints.png)
## Sending requests
Pick a verb + path, then **Send**. Successful calls show status and JSON in
**RESPONSE**.
![Request and response](images/web-ui/request-response.png)
### Request parameters
For `POST` / `PUT` / `PATCH`, **Request parameters** lists schema fields
(dotted names for nested OpenStack envelopes) with types, optional flags, and
example values. The Request body JSON is built from those fields.
![Request parameters](images/web-ui/request-parameters.png)
## Authentication drawer
Sign in with lab Keystone users (`admin` / `secret`, projects such as `admin`
or `demo`). Optional paste of an existing `X-Auth-Token`.
![Authentication drawer](images/web-ui/authentication.png)
## Environment drawer ## Environment drawer
- **OpenStack API pack** — list series, activate pack, set microversion overrides - Runtime / catalog / active **microversion**, plus live cloud inventory
- Apply remounts schema routes immediately (servers, nets, volumes…)
![Environment drawer](images/web-ui/environment.png)
## API catalog drawer
- Select an OpenStack series card (`os · yoga` …), choose a **microversion** on
the card, then **Apply as runtime** (choice is remembered across reloads)
![API catalog drawer](images/web-ui/api-catalog.png)
## Data drawer ## Data drawer
- **Load demo cloud**`POST /ui/api/demo/load``seed_openstack_demo` - **Load demo cloud**sized clusters (small / large / big)
- **Unload / minimal** reset to minimal seed - **Reset to minimal** — minimal lab seed
![Data drawer](images/web-ui/data.png)
## History drawer
Recent console calls with method, URL, and status.
![History drawer](images/web-ui/history.png)
## Help · Compatibility
Pack surface coverage for the active series (declared / implemented ops,
services, verb mix).
![Help compatibility](images/web-ui/help-compatibility.png)
## Branding ## Branding
+29 -16
View File
@@ -2,8 +2,16 @@
# Pulumi OpenStack tests (`pulumi-tests`) # Pulumi OpenStack tests (`pulumi-tests`)
Coverage lab that **maximises `pulumi_openstack`**, then probes remaining pack **100% coverage = HTTP contract matrix** (every series-pack operation + synthetic
operations over HTTP with **non-empty body checks** and **full method coverage**. `HEAD` on each GET path), not the number of `pulumi_openstack` resources.
Layer A (gate): pack ops × yoga→dalmatian on **real service ports**
([docs/ports.md](../docs/ports.md)), microversions when the pack declares them,
non-empty success bodies for entity/collection envelopes, `probed == declared + HEAD`,
`critical=0`.
Layer B (smoke): `pulumi_openstack` lifecycle — maximises provider surface; does
**not** define 100%.
## Quick start ## Quick start
@@ -14,8 +22,8 @@ make pulumi-tests
# or # or
cd pulumi-tests cd pulumi-tests
make up && make build make up && make build
make test-pulumi-smoke # fast: collection GET only make test-pulumi-smoke # fast: collection GET + HEAD
make test-pulumi # full: all pack ops × all HTTP methods make test-pulumi # full Layer A matrix × all series
open reports/pulumi-report.html open reports/pulumi-report.html
``` ```
@@ -23,28 +31,33 @@ open reports/pulumi-report.html
| Target | Mode | What is exercised | | Target | Mode | What is exercised |
|---|---|---| |---|---|---|
| `make test-pulumi-smoke` | Smoke (`TEST_SMOKE=1`) | `pulumi_openstack` + **collection GET** nonempty checks (fast) | | `make test-pulumi-smoke` | Smoke (`TEST_SMOKE=1`) | Layer B + **collection GET + HEAD** nonempty checks (fast) |
| `make pulumi-tests` / `make test-pulumi` | Full lifecycle | `pulumi_openstack` + **every pack operation × GET/POST/PUT/PATCH/DELETE**, completeness assert (`total == pack size`), nonempty bodies on succeeded responses (DELETE/204 may be empty) | | `make pulumi-tests` / `make test-pulumi` | Full matrix | Layer B + **every pack op × GET/POST/PUT/PATCH/DELETE** + **synthetic HEAD per GET**, completeness (`total == declared + HEAD`), nonempty bodies on succeeded responses (DELETE/204/HEAD may be empty) |
Pack sizes (ops): yoga ~1060 → antelope ~1108 → caracal ~1196 → **dalmatian ~1357**. Pack sizes (ops, without HEAD): yoga ~1060 → antelope ~1108 → caracal ~1196 → **dalmatian ~1357**.
## What runs (per series: yoga → dalmatian) ## What runs (per series: yoga → dalmatian)
1. Activate OpenStack series pack 1. Activate OpenStack series pack
2. **`pulumi up`** `programs/os_coverage` via Automation API — creates/looks up 2. **`pulumi up`** `programs/os_coverage` (Layer B — provider smoke)
resources with `pulumi_openstack` (identity, images, compute, networking, 3. Assert stack exports are non-empty (Layer B)
blockstorage, objectstorage, dns, orchestration) 4. HTTP contract matrix on **catalog ports** (Keystone token → Nova `:8774`, Neutron `:9696`, …)
3. Assert **every stack export is non-empty** 5. Assert `probed == declared + HEAD`, `critical=0`, nonempty JSON on success bodies
4. HTTP-probe pack operations (smoke: collection GET; full: all methods lifecycle)
5. Assert coverage completeness + nonempty JSON on successful body responses
6. `pulumi destroy` 6. `pulumi destroy`
7. Write `pulumi-report.html` + `pulumi-junit.xml` 7. Write `pulumi-report.html` + `pulumi-junit.xml` + verb histogram (incl. HEAD)
## Reports ## Reports
| File | Contents | | File | Contents |
|---|---| |---|---|
| `reports/pulumi-report.html` | HTML summary (expected vs actual + method breakdown) | | `reports/pulumi-report.html` | HTML summary (expected vs actual + method breakdown incl. HEAD) |
| `reports/pulumi-junit.xml` | JUnit | | `reports/pulumi-junit.xml` | JUnit |
| `reports/series-<name>.json` | Per-series pulumi + HTTP details | | `reports/series-<name>.json` | Per-series pulumi + HTTP details |
| `reports/summary.json` | Aggregates | | `reports/summary.json` | Aggregates (`http_total` / `http_expected`, `http_critical`) |
## Approximate (not blockers)
Nova create may skip a long `BUILD` window; many Nova actions only flip server
status; Placement mutations and Octavia listeners/pools are largely schema /
`os_api_objects`; Neutron `router:external` is derived from the shared `public`
network name.
+28 -16
View File
@@ -2,8 +2,16 @@
# Тесты Pulumi OpenStack (`pulumi-tests`) # Тесты Pulumi OpenStack (`pulumi-tests`)
Лаборатория покрытия: **максимально `pulumi_openstack`**, затем HTTP-probe **100% покрытия = HTTP contract matrix** (каждая операция series-pack +
остальных pack-операций с проверкой **непустых тел** и **полным покрытием методов**. синтетический `HEAD` на каждый GET path), а не число ресурсов `pulumi_openstack`.
Layer A (гейт): pack ops × yoga→dalmatian на **реальных портах сервисов**
([docs/ports.md](../docs/ports.md) / [docs/ru/ports.md](../docs/ru/ports.md)),
microversions где pack их задаёт, nonempty тела успешных ответов,
`probed == declared + HEAD`, `critical=0`.
Layer B (smoke): lifecycle `pulumi_openstack` — расширяет provider surface; **не**
определяет 100%.
## Быстрый старт ## Быстрый старт
@@ -14,8 +22,8 @@ make pulumi-tests
# или # или
cd pulumi-tests cd pulumi-tests
make up && make build make up && make build
make test-pulumi-smoke # быстро: только collection GET make test-pulumi-smoke # быстро: collection GET + HEAD
make test-pulumi # полный: все ручки пака × все HTTP-методы make test-pulumi # полная Layer A matrix × все серии
open reports/pulumi-report.html open reports/pulumi-report.html
``` ```
@@ -23,28 +31,32 @@ open reports/pulumi-report.html
| Цель | Режим | Что проверяется | | Цель | Режим | Что проверяется |
|---|---|---| |---|---|---|
| `make test-pulumi-smoke` | Smoke (`TEST_SMOKE=1`) | `pulumi_openstack` + **collection GET** с nonempty (быстро) | | `make test-pulumi-smoke` | Smoke (`TEST_SMOKE=1`) | Layer B + **collection GET + HEAD** с nonempty (быстро) |
| `make pulumi-tests` / `make test-pulumi` | Полный lifecycle | `pulumi_openstack` + **все операции пака × GET/POST/PUT/PATCH/DELETE**, assert полноты (`total == размер пака`), nonempty тел успешных ответов (DELETE/204 могут быть пустыми) | | `make pulumi-tests` / `make test-pulumi` | Полная matrix | Layer B + **все ops пака × GET/POST/PUT/PATCH/DELETE** + **синтетический HEAD на каждый GET**, полнота (`total == declared + HEAD`), nonempty тел (DELETE/204/HEAD могут быть пустыми) |
Размеры паков (ops): yoga ~1060 → antelope ~1108 → caracal ~1196 → **dalmatian ~1357**. Размеры паков (ops без HEAD): yoga ~1060 → antelope ~1108 → caracal ~1196 → **dalmatian ~1357**.
## Что выполняется (на каждую серию yoga → dalmatian) ## Что выполняется (на каждую серию yoga → dalmatian)
1. Активация pack серии OpenStack 1. Активация pack серии OpenStack
2. **`pulumi up`** программы `programs/os_coverage` через Automation API — 2. **`pulumi up`** `programs/os_coverage` (Layer B — provider smoke)
ресурсы через `pulumi_openstack` (identity, images, compute, networking, 3. Проверка непустых export стека (Layer B)
blockstorage, objectstorage, dns, orchestration) 4. HTTP contract matrix на **портах каталога** (токен Keystone → Nova `:8774`, Neutron `:9696`, …)
3. Проверка: **каждый export стека непустой** 5. Assert `probed == declared + HEAD`, `critical=0`, nonempty JSON
4. HTTP-probe pack-операций (smoke: collection GET; полный: lifecycle всех методов)
5. Assert полноты покрытия + nonempty JSON у успешных ответов с телом
6. `pulumi destroy` 6. `pulumi destroy`
7. Отчёты `pulumi-report.html` + `pulumi-junit.xml` 7. Отчёты HTML/JUnit + histogram глаголов (включая HEAD)
## Отчёты ## Отчёты
| Файл | Содержимое | | Файл | Содержимое |
|---|---| |---|---|
| `reports/pulumi-report.html` | HTML-сводка (expected vs actual + breakdown методов) | | `reports/pulumi-report.html` | HTML-сводка (expected vs actual + breakdown методов вкл. HEAD) |
| `reports/pulumi-junit.xml` | JUnit | | `reports/pulumi-junit.xml` | JUnit |
| `reports/series-<name>.json` | Детали pulumi + HTTP по серии | | `reports/series-<name>.json` | Детали pulumi + HTTP по серии |
| `reports/summary.json` | Агрегаты | | `reports/summary.json` | Агрегаты (`http_total` / `http_expected`, `http_critical`) |
## Approximate (не блокеры)
Nova create может пропускать длинное окно `BUILD`; многие Nova actions только
меняют status; Placement mutations и Octavia listeners/pools в основном schema /
`os_api_objects`; Neutron `router:external` выводится из shared сети `public`.
+2 -2
View File
@@ -132,8 +132,8 @@ services:
- ../docker/gateway/openstack-ports.conf:/etc/nginx/conf.d/default.conf:ro - ../docker/gateway/openstack-ports.conf:/etc/nginx/conf.d/default.conf:ro
- ../docker/tls/server.crt:/etc/nginx/tls/server.crt:ro - ../docker/tls/server.crt:/etc/nginx/tls/server.crt:ro
- ../docker/tls/server.key:/etc/nginx/tls/server.key:ro - ../docker/tls/server.key:/etc/nginx/tls/server.key:ro
ports: # No host port publish — pulumi-runner reaches api-gateway on the lab
- "127.0.0.1:15000:5000" # network (avoids colliding with `make up-local` on :15000).
pulumi-runner: pulumi-runner:
build: build:
+38 -13
View File
@@ -9,19 +9,28 @@ from typing import Any
from _lib.validate import payload_nonempty from _lib.validate import payload_nonempty
# Methods that normally return a JSON body on success (DELETE / 204 may be empty). # Methods that normally return a JSON body on success (DELETE / 204 / HEAD may be empty).
_BODY_METHODS = frozenset({"GET", "POST", "PUT", "PATCH"}) _BODY_METHODS = frozenset({"GET", "POST", "PUT", "PATCH"})
_VERB_ORDER = ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD")
def _expected_ops(packs: dict[str, Any], *, collections_only: bool) -> int: def _expected_ops(packs: dict[str, Any], *, collections_only: bool) -> tuple[int, int, int]:
"""Return (expected_total, declared_pack_ops, synthetic_head_ops)."""
from app.openstack.surface_probe import count_declared_ops
if not collections_only: if not collections_only:
return sum(len(p.operations) for p in packs.values()) declared, heads = count_declared_ops(packs)
total = 0 return declared + heads, declared, heads
declared = 0
heads = 0
for pack in packs.values(): for pack in packs.values():
for op in pack.operations: for op in pack.operations:
if op.method == "GET" and "{" not in op.path: if op.method == "GET" and "{" not in op.path:
total += 1 declared += 1
return total heads += 1
return declared + heads, declared, heads
def _methods_breakdown(results: list[Any]) -> dict[str, int]: def _methods_breakdown(results: list[Any]) -> dict[str, int]:
@@ -30,16 +39,16 @@ def _methods_breakdown(results: list[Any]) -> dict[str, int]:
method = getattr(r, "method", None) or (r.get("method") if isinstance(r, dict) else None) method = getattr(r, "method", None) or (r.get("method") if isinstance(r, dict) else None)
if method: if method:
counts[str(method).upper()] += 1 counts[str(method).upper()] += 1
return {m: counts.get(m, 0) for m in ("GET", "POST", "PUT", "PATCH", "DELETE")} return {m: counts.get(m, 0) for m in _VERB_ORDER}
def _nonempty_from_lifecycle(report: Any) -> list[dict[str, Any]]: def _nonempty_from_lifecycle(report: Any) -> list[dict[str, Any]]:
"""Check succeeded lifecycle bodies (skip DELETE / 204 / 202 / no-body).""" """Check succeeded lifecycle bodies (skip DELETE / HEAD / 204 / 202 / no-body)."""
failures: list[dict[str, Any]] = [] failures: list[dict[str, Any]] = []
for r in report.results: for r in report.results:
if not r.succeeded: if not r.succeeded:
continue continue
if r.method == "DELETE" or r.status in {202, 204}: if r.method in {"DELETE", "HEAD"} or r.status in {202, 204}:
continue continue
if r.method not in _BODY_METHODS: if r.method not in _BODY_METHODS:
continue continue
@@ -74,6 +83,8 @@ def _nonempty_smoke_collections(
fill_path, fill_path,
http_request, http_request,
issue_token, issue_token,
microversion_headers,
service_base_url,
_seed_context, _seed_context,
) )
@@ -96,8 +107,14 @@ def _nonempty_smoke_collections(
"account": project_id, "account": project_id,
}, },
) )
url = f"{host.rstrip('/')}{path}" url = f"{service_base_url(host, pack.name, port=pack.port)}{path}"
status, payload = http_request(op.method, url, token=token, service=pack.name) status, payload = http_request(
op.method,
url,
token=token,
service=pack.name,
headers=microversion_headers(pack, op) or None,
)
if status not in SUCCESS or status == 204: if status not in SUCCESS or status == 204:
continue continue
if not payload_nonempty(payload, collection_key=op.collection_key, method=op.method): if not payload_nonempty(payload, collection_key=op.collection_key, method=op.method):
@@ -134,7 +151,7 @@ def probe_pack_operations(
) )
packs = load_series_pack(series) packs = load_series_pack(series)
expected_ops = _expected_ops(packs, collections_only=collections_only) expected_ops, declared_ops, head_ops = _expected_ops(packs, collections_only=collections_only)
methods = _methods_breakdown(report.results) methods = _methods_breakdown(report.results)
coverage_incomplete = len(report.results) != expected_ops coverage_incomplete = len(report.results) != expected_ops
@@ -168,23 +185,31 @@ def probe_pack_operations(
"method": "*", "method": "*",
"path": "*", "path": "*",
"status": 0, "status": 0,
"detail": f"coverage_incomplete: total={len(report.results)} expected_ops={expected_ops}", "detail": (
f"coverage_incomplete: total={len(report.results)} "
f"expected_ops={expected_ops} "
f"(declared={declared_ops} + HEAD={head_ops})"
),
"ok": False, "ok": False,
"nonempty": True, "nonempty": True,
} }
) )
critical = len(coverage_failures) + len(probe_failures) + len(nonempty_failures)
return { return {
"series": series, "series": series,
"host": host, "host": host,
"mode": report.mode, "mode": report.mode,
"total": len(report.results), "total": len(report.results),
"expected_ops": expected_ops, "expected_ops": expected_ops,
"declared_ops": declared_ops,
"head_ops": head_ops,
"coverage_incomplete": coverage_incomplete, "coverage_incomplete": coverage_incomplete,
"methods": methods, "methods": methods,
"ok_count": len(report.results) - len(report.failures), "ok_count": len(report.results) - len(report.failures),
"fail_count": len(report.failures) + (1 if coverage_incomplete else 0), "fail_count": len(report.failures) + (1 if coverage_incomplete else 0),
"nonempty_fail_count": len(nonempty_failures), "nonempty_fail_count": len(nonempty_failures),
"critical": critical,
"results": [ "results": [
{ {
"service": r.service, "service": r.service,
+6 -4
View File
@@ -12,7 +12,9 @@ SERIES_ORDER = ("yoga", "antelope", "caracal", "dalmatian")
def _methods_line(methods: dict[str, Any]) -> str: def _methods_line(methods: dict[str, Any]) -> str:
return " ".join(f"{m}={methods.get(m, 0)}" for m in ("GET", "POST", "PUT", "PATCH", "DELETE")) return " ".join(
f"{m}={methods.get(m, 0)}" for m in ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD")
)
def render_html(summary: dict[str, Any], series_reports: list[dict[str, Any]]) -> str: def render_html(summary: dict[str, Any], series_reports: list[dict[str, Any]]) -> str:
@@ -33,8 +35,8 @@ def render_html(summary: dict[str, Any], series_reports: list[dict[str, Any]]) -
</div> </div>
<div class="stats"> <div class="stats">
<span class="ok">http ok={http.get("ok_count", 0)}</span> <span class="ok">http ok={http.get("ok_count", 0)}</span>
<span class="fail">http fail={http.get("fail_count", 0)} nonempty_fail={http.get("nonempty_fail_count", 0)}</span> <span class="fail">http fail={http.get("fail_count", 0)} nonempty_fail={http.get("nonempty_fail_count", 0)} critical={http.get("critical", 0)}</span>
<span>http total={http.get("total", 0)}/{http.get("expected_ops", "?")}</span> <span>http total={http.get("total", 0)}/{http.get("expected_ops", "?")} (declared={http.get("declared_ops", "?")}+HEAD={http.get("head_ops", "?")})</span>
</div> </div>
<div class="stats muted"> <div class="stats muted">
methods: {html.escape(_methods_line(http.get("methods") or {}))} methods: {html.escape(_methods_line(http.get("methods") or {}))}
@@ -92,7 +94,7 @@ def render_html(summary: dict[str, Any], series_reports: list[dict[str, Any]]) -
<body> <body>
<header> <header>
<h1>Pulumi OpenStack API coverage</h1> <h1>Pulumi OpenStack API coverage</h1>
<p class="muted">Generated {html.escape(generated)} · pulumi_openstack primary + HTTP pack probe with non-empty checks</p> <p class="muted">Generated {html.escape(generated)} · <strong>100% = HTTP contract matrix</strong> (pack ops + synthetic HEAD), not pulumi_openstack resource count. Layer B provider lifecycle is smoke only.</p>
</header> </header>
<main> <main>
<div class="summary"> <div class="summary">
+25 -3
View File
@@ -233,10 +233,20 @@ def main() -> int:
"total": 0, "total": 0,
"expected_ops": 0, "expected_ops": 0,
"coverage_incomplete": False, "coverage_incomplete": False,
"methods": {"GET": 0, "POST": 0, "PUT": 0, "PATCH": 0, "DELETE": 0}, "methods": {
"GET": 0,
"POST": 0,
"PUT": 0,
"PATCH": 0,
"DELETE": 0,
"HEAD": 0,
},
"ok_count": 0, "ok_count": 0,
"fail_count": 0, "fail_count": 0,
"nonempty_fail_count": 0, "nonempty_fail_count": 0,
"critical": 0,
"declared_ops": 0,
"head_ops": 0,
"results": [], "results": [],
"failures": [], "failures": [],
} }
@@ -245,12 +255,15 @@ def main() -> int:
http = run_http_coverage(series, collections_only=collections_only) http = run_http_coverage(series, collections_only=collections_only)
methods = http.get("methods") or {} methods = http.get("methods") or {}
methods_s = " ".join( methods_s = " ".join(
f"{m}={methods.get(m, 0)}" for m in ("GET", "POST", "PUT", "PATCH", "DELETE") f"{m}={methods.get(m, 0)}"
for m in ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD")
) )
print( print(
f"{series}: http ok={http.get('ok_count')} fail={http.get('fail_count')} " f"{series}: http ok={http.get('ok_count')} fail={http.get('fail_count')} "
f"nonempty_fail={http.get('nonempty_fail_count')} " f"nonempty_fail={http.get('nonempty_fail_count')} "
f"critical={http.get('critical')} "
f"total={http.get('total')}/{http.get('expected_ops')} " f"total={http.get('total')}/{http.get('expected_ops')} "
f"(declared={http.get('declared_ops')}+HEAD={http.get('head_ops')}) "
f"coverage_incomplete={http.get('coverage_incomplete')} " f"coverage_incomplete={http.get('coverage_incomplete')} "
f"methods[{methods_s}]" f"methods[{methods_s}]"
) )
@@ -273,10 +286,16 @@ def main() -> int:
int(r["http"].get("fail_count", 0)) + int(r["http"].get("nonempty_fail_count", 0)) int(r["http"].get("fail_count", 0)) + int(r["http"].get("nonempty_fail_count", 0))
for r in series_reports for r in series_reports
), ),
"http_critical": sum(int(r["http"].get("critical", 0)) for r in series_reports),
"http_declared": sum(int(r["http"].get("declared_ops", 0)) for r in series_reports),
"http_head": sum(int(r["http"].get("head_ops", 0)) for r in series_reports),
"http_total": sum(int(r["http"].get("total", 0)) for r in series_reports),
"http_expected": sum(int(r["http"].get("expected_ops", 0)) for r in series_reports),
"coverage_incomplete": sum( "coverage_incomplete": sum(
1 for r in series_reports if r["http"].get("coverage_incomplete") 1 for r in series_reports if r["http"].get("coverage_incomplete")
), ),
"collections_only": collections_only, "collections_only": collections_only,
"definition": "100% = HTTP contract matrix (pack ops + synthetic HEAD), not pulumi_openstack resource count",
} }
html_path = write_html(REPORT_DIR, summary, series_reports) html_path = write_html(REPORT_DIR, summary, series_reports)
(REPORT_DIR / "summary.json").write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8") (REPORT_DIR / "summary.json").write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
@@ -285,7 +304,10 @@ def main() -> int:
print(json.dumps(summary, indent=2)) print(json.dumps(summary, indent=2))
failed = ( failed = (
summary["pulumi_fail"] > 0 or summary["http_fail"] > 0 or summary["coverage_incomplete"] > 0 summary["pulumi_fail"] > 0
or summary["http_fail"] > 0
or summary["coverage_incomplete"] > 0
or summary["http_critical"] > 0
) )
return 1 if failed else 0 return 1 if failed else 0
File diff suppressed because one or more lines are too long
+18 -18
View File
@@ -21,14 +21,14 @@
<body> <body>
<header> <header>
<h1>Pulumi OpenStack API coverage</h1> <h1>Pulumi OpenStack API coverage</h1>
<p class="muted">Generated 2026-07-17 13:12:06 UTC · pulumi_openstack primary + HTTP pack probe with non-empty checks</p> <p class="muted">Generated 2026-07-18 03:50:29 UTC · <strong>100% = HTTP contract matrix</strong> (pack ops + synthetic HEAD), not pulumi_openstack resource count. Layer B provider lifecycle is smoke only.</p>
</header> </header>
<main> <main>
<div class="summary"> <div class="summary">
<div><strong>4</strong><span class="muted"> series</span></div> <div><strong>4</strong><span class="muted"> series</span></div>
<div><strong class="ok">4</strong><span class="muted"> pulumi stacks ok</span></div> <div><strong class="ok">4</strong><span class="muted"> pulumi stacks ok</span></div>
<div><strong class="fail">0</strong><span class="muted"> pulumi failures</span></div> <div><strong class="fail">0</strong><span class="muted"> pulumi failures</span></div>
<div><strong class="ok">4721</strong><span class="muted"> http ops ok</span></div> <div><strong class="ok">6514</strong><span class="muted"> http ops ok</span></div>
<div><strong class="fail">0</strong><span class="muted"> http / nonempty fails</span></div> <div><strong class="fail">0</strong><span class="muted"> http / nonempty fails</span></div>
</div> </div>
<h2>Series</h2> <h2>Series</h2>
@@ -41,12 +41,12 @@
<span class="fail">empty exports=0</span> <span class="fail">empty exports=0</span>
</div> </div>
<div class="stats"> <div class="stats">
<span class="ok">http ok=1060</span> <span class="ok">http ok=1464</span>
<span class="fail">http fail=0 nonempty_fail=0</span> <span class="fail">http fail=0 nonempty_fail=0 critical=0</span>
<span>http total=1060/1060</span> <span>http total=1464/1464 (declared=1060+HEAD=404)</span>
</div> </div>
<div class="stats muted"> <div class="stats muted">
methods: GET=404 POST=168 PUT=171 PATCH=155 DELETE=162 methods: GET=404 POST=168 PUT=171 PATCH=155 DELETE=162 HEAD=404
</div> </div>
</div> </div>
@@ -58,12 +58,12 @@
<span class="fail">empty exports=0</span> <span class="fail">empty exports=0</span>
</div> </div>
<div class="stats"> <div class="stats">
<span class="ok">http ok=1108</span> <span class="ok">http ok=1530</span>
<span class="fail">http fail=0 nonempty_fail=0</span> <span class="fail">http fail=0 nonempty_fail=0 critical=0</span>
<span>http total=1108/1108</span> <span>http total=1530/1530 (declared=1108+HEAD=422)</span>
</div> </div>
<div class="stats muted"> <div class="stats muted">
methods: GET=422 POST=177 PUT=178 PATCH=162 DELETE=169 methods: GET=422 POST=177 PUT=178 PATCH=162 DELETE=169 HEAD=422
</div> </div>
</div> </div>
@@ -75,12 +75,12 @@
<span class="fail">empty exports=0</span> <span class="fail">empty exports=0</span>
</div> </div>
<div class="stats"> <div class="stats">
<span class="ok">http ok=1196</span> <span class="ok">http ok=1649</span>
<span class="fail">http fail=0 nonempty_fail=0</span> <span class="fail">http fail=0 nonempty_fail=0 critical=0</span>
<span>http total=1196/1196</span> <span>http total=1649/1649 (declared=1196+HEAD=453)</span>
</div> </div>
<div class="stats muted"> <div class="stats muted">
methods: GET=453 POST=192 PUT=192 PATCH=176 DELETE=183 methods: GET=453 POST=192 PUT=192 PATCH=176 DELETE=183 HEAD=453
</div> </div>
</div> </div>
@@ -92,12 +92,12 @@
<span class="fail">empty exports=0</span> <span class="fail">empty exports=0</span>
</div> </div>
<div class="stats"> <div class="stats">
<span class="ok">http ok=1357</span> <span class="ok">http ok=1871</span>
<span class="fail">http fail=0 nonempty_fail=0</span> <span class="fail">http fail=0 nonempty_fail=0 critical=0</span>
<span>http total=1357/1357</span> <span>http total=1871/1871 (declared=1357+HEAD=514)</span>
</div> </div>
<div class="stats muted"> <div class="stats muted">
methods: GET=514 POST=217 PUT=217 PATCH=201 DELETE=208 methods: GET=514 POST=217 PUT=217 PATCH=201 DELETE=208 HEAD=514
</div> </div>
</div></div> </div></div>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -2,8 +2,14 @@
"series_count": 4, "series_count": 4,
"pulumi_ok": 4, "pulumi_ok": 4,
"pulumi_fail": 0, "pulumi_fail": 0,
"http_ok": 4721, "http_ok": 6514,
"http_fail": 0, "http_fail": 0,
"http_critical": 0,
"http_declared": 4721,
"http_head": 1793,
"http_total": 6514,
"http_expected": 6514,
"coverage_incomplete": 0, "coverage_incomplete": 0,
"collections_only": false "collections_only": false,
"definition": "100% = HTTP contract matrix (pack ops + synthetic HEAD), not pulumi_openstack resource count"
} }
+2
View File
@@ -49,6 +49,8 @@ select = ["E", "F", "I", "UP", "B", "ASYNC", "S", "RUF"]
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["S101", "S105"] "tests/**/*.py" = ["S101", "S105"]
"tools/os_api_inventory/request_body_catalog.py" = ["E501"]
"tools/os_api_inventory/import_openapi_bodies.py" = ["S310"]
[tool.mypy] [tool.mypy]
python_version = "3.13" python_version = "3.13"
@@ -0,0 +1,562 @@
"""Specialized IaaS handlers: auth, CRUD read-after-write, Nova actions.
Runs against a live api-gateway after minimal/demo seed. Prefers real service
ports when reachable; falls back to Keystone gateway + X-OpenStack-Route-Service.
"""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.request
import uuid
from typing import Any
import pytest
pytestmark = pytest.mark.integration
def _probe(url: str, timeout: float = 2.0) -> bool:
try:
with urllib.request.urlopen(url, timeout=timeout) as res:
return res.status == 200
except Exception:
return False
def _pick_keystone() -> str:
candidates = [
os.environ.get("OS_PROBE_HOST"),
os.environ.get("OS_HOST"),
"http://127.0.0.1:15000",
"http://127.0.0.1:5000",
"http://api-gateway:5000",
"http://localhost:5000",
]
for host in candidates:
if not host:
continue
base = host.rstrip("/")
if _probe(f"{base}/health/live") or _probe(f"{base}/v3"):
return base
return ""
KEYSTONE = _pick_keystone()
# Real OpenStack default ports (compose publishes 1:1; local override keeps them).
_PORTS = {
"keystone": 5000,
"nova": 8774,
"neutron": 9696,
"glance": 9292,
"cinder": 8776,
"placement": 8003,
"heat": 8004,
"swift": 8080,
"ironic": 6385,
"octavia": 9876,
}
def _service_base(service: str) -> tuple[str, str | None]:
"""Return (base_url, route_service_header_or_None)."""
if service == "keystone":
return KEYSTONE, None
port = _PORTS[service]
candidates = [port]
if service == "swift":
# Local override often maps Swift to host 18080.
candidates = [18080, 8080]
for p in candidates:
base = f"http://127.0.0.1:{p}"
# Any HTTP response (incl. 401/404) means the port is published.
try:
urllib.request.urlopen(base + "/", timeout=1)
return base, None
except urllib.error.HTTPError:
return base, None
except Exception:
continue
return KEYSTONE, service
@pytest.fixture(scope="module", autouse=True)
def _require_gateway():
if not KEYSTONE:
pytest.skip("OpenStack gateway unreachable")
def _request(
method: str,
service: str,
path: str,
*,
token: str | None = None,
data: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
raw_body: bytes | None = None,
) -> tuple[int, dict[str, str], Any]:
base, route_svc = _service_base(service)
body = raw_body
hdrs = {"Accept": "application/json"}
if data is not None:
hdrs["Content-Type"] = "application/json"
body = json.dumps(data).encode()
if token:
hdrs["X-Auth-Token"] = token
if route_svc:
hdrs["X-OpenStack-Route-Service"] = route_svc
if headers:
hdrs.update(headers)
req = urllib.request.Request(f"{base}{path}", data=body, headers=hdrs, method=method)
try:
with urllib.request.urlopen(req, timeout=30) as res:
raw = res.read().decode()
try:
parsed: Any = json.loads(raw) if raw else None
except json.JSONDecodeError:
parsed = raw
return res.status, {k: v for k, v in res.headers.items()}, parsed
except urllib.error.HTTPError as exc:
raw = exc.read().decode()
try:
parsed = json.loads(raw) if raw else None
except json.JSONDecodeError:
parsed = raw
return exc.code, {k: v for k, v in exc.headers.items()}, parsed
def _auth(
*,
user: str = "demo",
project: str = "demo",
password: str = "secret",
) -> tuple[str, str, dict[str, Any]]:
status, headers, body = _request(
"POST",
"keystone",
"/v3/auth/tokens",
data={
"auth": {
"identity": {
"methods": ["password"],
"password": {
"user": {
"name": user,
"domain": {"name": "Default"},
"password": password,
}
},
},
"scope": {"project": {"name": project, "domain": {"name": "Default"}}},
}
},
)
token = headers.get("X-Subject-Token") or headers.get("x-subject-token")
assert status == 201, (status, body)
assert token
project = ((body or {}).get("token") or {}).get("project") or {}
project_id = str(project.get("id") or "")
assert project_id
return token, project_id, body or {}
@pytest.fixture(scope="module")
def auth_ctx() -> tuple[str, str, dict[str, Any]]:
return _auth()
def test_keystone_token_catalog_ports(auth_ctx: tuple[str, str, dict[str, Any]]) -> None:
_token, _pid, body = auth_ctx
catalog = (body.get("token") or {}).get("catalog") or []
by_type = {e.get("type"): e for e in catalog if isinstance(e, dict)}
expected = {
"identity": ":5000",
"compute": ":8774",
"network": ":9696",
"image": ":9292",
"volumev3": ":8776",
"placement": ":8003",
"orchestration": ":8004",
"object-store": ":8080",
"baremetal": ":6385",
"load-balancer": ":9876",
}
for typ, port_frag in expected.items():
entry = by_type.get(typ)
assert entry, f"missing catalog type {typ}"
urls = [
ep.get("url") or ""
for ep in entry.get("endpoints") or []
if ep.get("interface") == "public"
]
assert any(port_frag in u for u in urls), (typ, urls)
def test_specialized_lists_nonempty_after_seed(auth_ctx: tuple[str, str, dict[str, Any]]) -> None:
token, pid, _ = auth_ctx
checks = [
("nova", "/v2.1/servers/detail", "servers", {"OpenStack-API-Version": "compute 2.79"}),
("nova", "/v2.1/flavors", "flavors", None),
("neutron", "/v2.0/networks", "networks", None),
("neutron", "/v2.0/subnets", "subnets", None),
("neutron", "/v2.0/floatingips", "floatingips", None),
("glance", "/v2/images", "images", None),
("cinder", "/v3/volumes/detail", "volumes", None),
(
"placement",
"/resource_providers",
"resource_providers",
{"OpenStack-API-Version": "placement 1.39"},
),
("heat", f"/v1/{pid}/stacks", "stacks", None),
("octavia", "/v2/lbaas/loadbalancers", "loadbalancers", None),
("ironic", "/v1/nodes", "nodes", {"OpenStack-API-Version": "baremetal 1.90"}),
]
for service, path, key, hdrs in checks:
status, _, body = _request("GET", service, path, token=token, headers=hdrs)
assert status == 200, (service, path, status, body)
items = (body or {}).get(key)
assert isinstance(items, list) and len(items) >= 1, (service, path, key, body)
status, _, body = _request("GET", "swift", f"/v1/AUTH_{pid}", token=token)
assert status == 200
assert isinstance(body, list) and len(body) >= 1
def test_nova_server_crud_and_actions(auth_ctx: tuple[str, str, dict[str, Any]]) -> None:
token, _pid, _ = auth_ctx
mv = {"OpenStack-API-Version": "compute 2.79"}
st, _, flavors = _request("GET", "nova", "/v2.1/flavors", token=token)
st, _, images = _request("GET", "glance", "/v2/images", token=token)
st, _, nets = _request("GET", "neutron", "/v2.0/networks", token=token)
flavor = (flavors or {}).get("flavors") or [{}]
image = (images or {}).get("images") or [{}]
networks = (nets or {}).get("networks") or [{}]
# Prefer tenant demo-net over shared public for boot.
net = next((n for n in networks if n.get("name") == "demo-net"), networks[0])
name = f"lc-{uuid.uuid4().hex[:8]}"
st, _, created = _request(
"POST",
"nova",
"/v2.1/servers",
token=token,
headers=mv,
data={
"server": {
"name": name,
"flavorRef": flavor[0]["id"],
"imageRef": image[0]["id"],
"networks": [{"uuid": net["id"]}],
}
},
)
assert st == 202, created
sid = (created or {}).get("server", {}).get("id")
assert sid
st, _, shown = _request("GET", "nova", f"/v2.1/servers/{sid}", token=token, headers=mv)
assert st == 200
assert shown["server"]["name"] == name
st, _, _ = _request(
"PUT",
"nova",
f"/v2.1/servers/{sid}",
token=token,
headers=mv,
data={"server": {"name": f"{name}-ren"}},
)
assert st == 200
for action, expect in (
({"os-stop": None}, "SHUTOFF"),
({"os-start": None}, "ACTIVE"),
({"reboot": {"type": "SOFT"}}, "ACTIVE"),
({"suspend": None}, "SUSPENDED"),
({"resume": None}, "ACTIVE"),
({"pause": None}, "PAUSED"),
({"unpause": None}, "ACTIVE"),
({"shelve": None}, "SHELVED"),
({"shelveOffload": None}, "SHELVED_OFFLOADED"),
({"unshelve": None}, "ACTIVE"),
):
st, _, _ = _request(
"POST",
"nova",
f"/v2.1/servers/{sid}/action",
token=token,
headers=mv,
data=action,
)
assert st == 202, action
st, _, shown = _request("GET", "nova", f"/v2.1/servers/{sid}", token=token, headers=mv)
assert shown["server"]["status"] == expect, (action, shown["server"]["status"])
st, _, _ = _request("DELETE", "nova", f"/v2.1/servers/{sid}", token=token, headers=mv)
assert st == 204
st, _, _ = _request("GET", "nova", f"/v2.1/servers/{sid}", token=token, headers=mv)
assert st == 404
def test_neutron_network_subnet_port_fip_crud(
auth_ctx: tuple[str, str, dict[str, Any]],
) -> None:
token, _pid, _ = auth_ctx
tag = uuid.uuid4().hex[:8]
st, _, created = _request(
"POST",
"neutron",
"/v2.0/networks",
token=token,
data={"network": {"name": f"n-{tag}", "admin_state_up": True}},
)
assert st == 201
nid = created["network"]["id"]
st, _, sub = _request(
"POST",
"neutron",
"/v2.0/subnets",
token=token,
data={
"subnet": {
"name": f"s-{tag}",
"network_id": nid,
"cidr": "10.210.0.0/24",
"ip_version": 4,
}
},
)
assert st == 201
sid = sub["subnet"]["id"]
st, _, port = _request(
"POST",
"neutron",
"/v2.0/ports",
token=token,
data={"port": {"name": f"p-{tag}", "network_id": nid}},
)
assert st == 201
pid = port["port"]["id"]
st, _, nets = _request("GET", "neutron", "/v2.0/networks", token=token)
assert any(n.get("id") == nid for n in nets.get("networks") or [])
public = next(n for n in nets["networks"] if n.get("name") == "public")
assert public.get("router:external") is True
st, _, fip = _request(
"POST",
"neutron",
"/v2.0/floatingips",
token=token,
data={"floatingip": {"floating_network_id": public["id"]}},
)
assert st == 201
fid = fip["floatingip"]["id"]
st, _, shown = _request("GET", "neutron", f"/v2.0/floatingips/{fid}", token=token)
assert st == 200
assert shown["floatingip"]["id"] == fid
st, _, _ = _request("DELETE", "neutron", f"/v2.0/floatingips/{fid}", token=token)
assert st == 204
st, _, _ = _request("DELETE", "neutron", f"/v2.0/ports/{pid}", token=token)
assert st == 204
st, _, _ = _request("DELETE", "neutron", f"/v2.0/subnets/{sid}", token=token)
assert st == 204
st, _, _ = _request("DELETE", "neutron", f"/v2.0/networks/{nid}", token=token)
assert st == 204
st, _, _ = _request("GET", "neutron", f"/v2.0/networks/{nid}", token=token)
assert st == 404
def test_glance_cinder_heat_octavia_ironic_swift_crud(
auth_ctx: tuple[str, str, dict[str, Any]],
) -> None:
token, project_id, _ = auth_ctx
tag = uuid.uuid4().hex[:8]
st, _, img = _request(
"POST",
"glance",
"/v2/images",
token=token,
data={"name": f"img-{tag}", "container_format": "bare", "disk_format": "qcow2"},
)
assert st == 201
iid = img["id"]
st, _, shown = _request("GET", "glance", f"/v2/images/{iid}", token=token)
assert st == 200 and shown["id"] == iid
st, _, _ = _request("DELETE", "glance", f"/v2/images/{iid}", token=token)
assert st == 204
st, _, vol = _request(
"POST",
"cinder",
"/v3/volumes",
token=token,
data={"volume": {"size": 1, "name": f"vol-{tag}"}},
)
assert st == 202
vid = vol["volume"]["id"]
st, _, shown = _request("GET", "cinder", f"/v3/volumes/{vid}", token=token)
assert st == 200 and shown["volume"]["id"] == vid
st, _, _ = _request(
"PUT",
"cinder",
f"/v3/volumes/{vid}",
token=token,
data={"volume": {"name": f"vol-{tag}-ren", "description": "lc"}},
)
assert st == 200
st, _, _ = _request("DELETE", "cinder", f"/v3/volumes/{vid}", token=token)
assert st == 202
st, _, _ = _request("GET", "cinder", f"/v3/volumes/{vid}", token=token)
assert st == 404
st, _, stack = _request(
"POST",
"heat",
f"/v1/{project_id}/stacks",
token=token,
data={
"stack_name": f"stk-{tag}",
"template": {"heat_template_version": "2015-04-30", "resources": {}},
},
)
assert st == 201
sid = stack["stack"]["id"]
sname = stack["stack"]["stack_name"]
st, _, _ = _request(
"PUT",
"heat",
f"/v1/{project_id}/stacks/{sname}/{sid}",
token=token,
data={
"template": {
"heat_template_version": "2015-04-30",
"description": "upd",
"resources": {},
},
"description": "updated",
},
)
assert st == 200
st, _, shown = _request("GET", "heat", f"/v1/{project_id}/stacks/{sname}/{sid}", token=token)
assert st == 200
assert shown["stack"]["stack_status"] == "UPDATE_COMPLETE"
st, _, _ = _request("DELETE", "heat", f"/v1/{project_id}/stacks/{sname}/{sid}", token=token)
assert st == 204
st, _, subs = _request("GET", "neutron", "/v2.0/subnets", token=token)
sub_id = (subs.get("subnets") or [{}])[0].get("id")
assert sub_id
st, _, lb = _request(
"POST",
"octavia",
"/v2/lbaas/loadbalancers",
token=token,
data={"loadbalancer": {"name": f"lb-{tag}", "vip_subnet_id": sub_id}},
)
assert st == 201
lbid = lb["loadbalancer"]["id"]
st, _, _ = _request(
"PUT",
"octavia",
f"/v2/lbaas/loadbalancers/{lbid}",
token=token,
data={"loadbalancer": {"name": f"lb-{tag}-ren"}},
)
assert st == 200
st, _, _ = _request("DELETE", "octavia", f"/v2/lbaas/loadbalancers/{lbid}", token=token)
assert st == 204
st, _, _ = _request("GET", "octavia", f"/v2/lbaas/loadbalancers/{lbid}", token=token)
assert st == 404
st, _, node = _request(
"POST",
"ironic",
"/v1/nodes",
token=token,
headers={"OpenStack-API-Version": "baremetal 1.90"},
data={"name": f"node-{tag}", "driver": "ipmi"},
)
assert st == 201
nuid = node["uuid"]
st, _, _ = _request(
"PUT",
"ironic",
f"/v1/nodes/{nuid}/states/power",
token=token,
headers={"OpenStack-API-Version": "baremetal 1.90"},
data={"target": "power on"},
)
assert st == 202
st, _, shown = _request(
"GET",
"ironic",
f"/v1/nodes/{nuid}",
token=token,
headers={"OpenStack-API-Version": "baremetal 1.90"},
)
assert shown.get("power_state") == "power on"
st, _, _ = _request(
"DELETE",
"ironic",
f"/v1/nodes/{nuid}",
token=token,
headers={"OpenStack-API-Version": "baremetal 1.90"},
)
assert st == 204
acct = f"AUTH_{project_id}"
cname = f"c-{tag}"
st, _, _ = _request("PUT", "swift", f"/v1/{acct}/{cname}", token=token)
assert st == 201
st, _, _ = _request(
"PUT",
"swift",
f"/v1/{acct}/{cname}/hello.txt",
token=token,
headers={"Content-Type": "text/plain"},
raw_body=b"hello",
)
assert st == 201
st, _, obj = _request("GET", "swift", f"/v1/{acct}/{cname}/hello.txt", token=token)
assert st == 200
st, _, _ = _request("DELETE", "swift", f"/v1/{acct}/{cname}/hello.txt", token=token)
assert st == 204
st, _, _ = _request("DELETE", "swift", f"/v1/{acct}/{cname}", token=token)
assert st == 204
st, _, containers = _request("GET", "swift", f"/v1/{acct}", token=token)
assert st == 200
assert not any(c.get("name") == cname for c in containers or [])
def test_project_scoped_token_required_for_nova() -> None:
status, headers, body = _request(
"POST",
"keystone",
"/v3/auth/tokens",
data={
"auth": {
"identity": {
"methods": ["password"],
"password": {
"user": {
"name": "demo",
"domain": {"name": "Default"},
"password": "secret",
}
},
}
}
},
)
token = headers.get("X-Subject-Token") or headers.get("x-subject-token")
assert status == 201 and token
st, _, err = _request("GET", "nova", "/v2.1/servers", token=token)
assert st in {401, 403}
assert isinstance(err, dict)
+34 -9
View File
@@ -8,10 +8,13 @@ import asyncpg
import pytest import pytest
from app.openstack.demo_cloud import ( from app.openstack.demo_cloud import (
DEMO_CLUSTER_SIZES,
DEMO_PROFILE, DEMO_PROFILE,
DEMO_SERVER_COUNT,
clear_openstack_state, clear_openstack_state,
demo_profile_name,
is_demo_profile,
openstack_demo_summary, openstack_demo_summary,
resolve_demo_size,
seed_openstack_demo, seed_openstack_demo,
) )
from app.openstack.seed import seed_openstack from app.openstack.seed import seed_openstack
@@ -41,15 +44,35 @@ async def conn():
await connection.close() await connection.close()
def test_resolve_demo_sizes() -> None:
small = resolve_demo_size("small")
large = resolve_demo_size("demo")
big = resolve_demo_size("big")
assert small.hypervisors == 3 and small.servers == 50
assert large.hypervisors == 10 and large.servers == 1000
assert big.hypervisors == 20 and big.servers == 2000
assert big.volumes == large.volumes * 2
assert small.extra_networks < large.extra_networks < big.extra_networks
assert small.keypairs_per_user < large.keypairs_per_user < big.keypairs_per_user
assert is_demo_profile(demo_profile_name("large"))
assert is_demo_profile(DEMO_PROFILE)
assert not is_demo_profile("minimal")
assert {cfg.name for cfg in DEMO_CLUSTER_SIZES.values()} == {"small", "large", "big"}
async def test_demo_seed_roundtrip(conn: asyncpg.Connection) -> None: async def test_demo_seed_roundtrip(conn: asyncpg.Connection) -> None:
await seed_openstack_demo(conn) # Prefer small for CI speed; still exercises full topology.
await seed_openstack_demo(conn, size="small")
summary = await openstack_demo_summary(conn) summary = await openstack_demo_summary(conn)
small = DEMO_CLUSTER_SIZES["small"]
assert summary["loaded"] is True assert summary["loaded"] is True
assert summary["servers"] == DEMO_SERVER_COUNT assert summary["servers"] == small.servers
assert summary["hypervisors"] == 16 assert summary["hypervisors"] == small.hypervisors
assert summary["volumes"] == small.volumes
assert summary["size"] == "small"
assert summary["profile"] == demo_profile_name("small")
assert summary["projects"] == 5 assert summary["projects"] == 5
assert summary["volumes"] == 600 assert len(summary["sizes"]) == 3
assert summary["profile"] == DEMO_PROFILE
await clear_openstack_state(conn) await clear_openstack_state(conn)
result = await seed_openstack(conn) result = await seed_openstack(conn)
@@ -59,8 +82,10 @@ async def test_demo_seed_roundtrip(conn: asyncpg.Connection) -> None:
assert summary["servers"] == 1 assert summary["servers"] == 1
assert summary["profile"] == "minimal" assert summary["profile"] == "minimal"
# Restore demo so a shared lab DB stays usable after the test. # Restore large so a shared lab DB stays usable after the test.
await seed_openstack_demo(conn) await seed_openstack_demo(conn, size="large")
summary = await openstack_demo_summary(conn) summary = await openstack_demo_summary(conn)
large = DEMO_CLUSTER_SIZES["large"]
assert summary["loaded"] is True assert summary["loaded"] is True
assert summary["servers"] == DEMO_SERVER_COUNT assert summary["servers"] == large.servers
assert summary["hypervisors"] == large.hypervisors
+136
View File
@@ -0,0 +1,136 @@
"""Request-body schema store and coverage tests."""
from __future__ import annotations
from app.openstack.contract_loader import load_series_pack
from app.openstack.request_bodies import clear_request_body_cache, missing_write_schemas
from app.openstack.request_examples import (
body_fields_from_example,
flatten_schema_fields,
schema_example,
unflatten_body,
)
from app.openstack.singular import singular
from app.web.openstack_catalog import openstack_method_payload
def test_singular_status_not_statu() -> None:
assert singular("status") == "status"
assert singular("statuses") == "status"
assert singular("networks") == "network"
assert singular("addresses") == "address"
def test_all_series_write_ops_have_request_schemas() -> None:
clear_request_body_cache()
for series in ("yoga", "antelope", "caracal", "dalmatian"):
packs = load_series_pack(series)
missing = missing_write_schemas(packs)
assert missing == [], f"{series} missing schemas: {missing[:10]}"
def test_vim_create_catalog_has_api_ref_fields() -> None:
clear_request_body_cache()
payload = openstack_method_payload(
major=9,
path="/v1.0/vims",
verb="POST",
runtime_version="openstack-dalmatian",
)
names = {field["name"] for field in payload["body_fields"]}
assert "vim.type" in names
assert "vim.auth_url" in names
assert "vim.auth_cred.username" in names
assert "vim.vim_project.name" in names
example = payload["body_example"]
assert example["vim"]["type"] == "openstack"
assert "auth_cred" in example["vim"]
assert example["vim"]["name"] == "example"
def test_status_create_uses_status_envelope() -> None:
clear_request_body_cache()
payload = openstack_method_payload(
major=9,
path="/v1/status",
verb="POST",
runtime_version="openstack-dalmatian",
)
assert "status" in payload["body_example"]
assert "statu" not in payload["body_example"]
names = {field["name"] for field in payload["body_fields"]}
assert "status.service" in names or "status.status" in names
def test_server_create_expands_nested_network_fields() -> None:
clear_request_body_cache()
payload = openstack_method_payload(
major=9,
path="/v2.1/servers",
verb="POST",
runtime_version="openstack-dalmatian",
)
example = payload["body_example"]
assert "server" in example
assert "flavorRef" in example["server"]
names = {field["name"] for field in payload["body_fields"]}
assert "server.flavorRef" in names
# Nested array object leaves from body_example (not a single JSON blob).
assert "server.networks.0.uuid" in names or "server.networks" in names
def test_body_fields_from_example_walks_nested_leaves() -> None:
fields = body_fields_from_example(
{
"vim": {
"name": "example",
"auth_cred": {"username": "admin", "password": "secret"},
"tags": ["a", "b"],
}
}
)
names = {f["name"] for f in fields}
assert "vim.name" in names
assert "vim.auth_cred.username" in names
assert "vim.auth_cred.password" in names
assert "vim.tags.0" in names
assert "vim.tags.1" in names
def test_flatten_and_unflatten_roundtrip() -> None:
schema = {
"type": "object",
"required": ["vim"],
"properties": {
"vim": {
"type": "object",
"required": ["name", "type"],
"properties": {
"name": {"type": "string", "example": "example"},
"type": {"type": "string", "example": "openstack"},
"auth_cred": {
"type": "object",
"properties": {
"username": {"type": "string", "example": "admin"},
},
},
},
}
},
}
fields = flatten_schema_fields(schema)
names = [f["name"] for f in fields]
assert "vim.name" in names
assert "vim.auth_cred.username" in names
example = schema_example(schema)
assert example["vim"]["name"] == "example"
nested = unflatten_body(
{
"vim.name": "example",
"vim.type": "openstack",
"vim.auth_cred.username": "admin",
}
)
assert nested == {
"vim": {"name": "example", "type": "openstack", "auth_cred": {"username": "admin"}}
}
+34
View File
@@ -0,0 +1,34 @@
"""OpenStack UI catalog payload tests."""
from app.openstack.request_bodies import clear_request_body_cache
from app.web.openstack_catalog import openstack_method_payload
def test_collection_create_body_uses_request_schema() -> None:
clear_request_body_cache()
payload = openstack_method_payload(
major=9,
path="/v1/status",
verb="POST",
runtime_version="openstack-dalmatian",
)
assert payload["service"] == "adjutant"
assert payload["body_fields"]
assert payload["body_example"]
# Must not be the old stub-only envelope.
assert payload["body_example"] != {"statu": {"name": "example"}}
assert "status" in payload["body_example"]
def test_action_body_example_uses_action_name() -> None:
clear_request_body_cache()
payload = openstack_method_payload(
major=9,
path="/v2.1/servers/{id}/action",
verb="POST",
runtime_version="openstack-dalmatian",
)
assert payload["body_fields"]
assert payload["body_example"]
# Wildcard server actions default to os-start in the schema catalog.
assert "os-start" in payload["body_example"]
+3 -3
View File
@@ -60,9 +60,9 @@ def _crud(
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Expand a resource into list/detail/create/show/update/delete + actions/nested.""" """Expand a resource into list/detail/create/show/update/delete + actions/nested."""
singular = key[:-1] if key.endswith("s") and not key.endswith("ss") else key from app.openstack.singular import singular as _singularize
if key.endswith("ies"):
singular = key[:-3] + "y" singular = _singularize(key)
ops: list[dict[str, Any]] = [ ops: list[dict[str, Any]] = [
{ {
"operation_id": f"{resource}_list", "operation_id": f"{resource}_list",
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Generate contracts/openstack/request_bodies/*.json for all pack write ops."""
from __future__ import annotations
import argparse
import json
import sys
from collections import defaultdict
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "tools"))
from os_api_inventory.request_body_catalog import schema_for_operation # noqa: E402
SERIES_DEFAULT = "dalmatian"
OUT_DIR = ROOT / "contracts" / "openstack" / "request_bodies"
def _load_ops(series: str) -> dict[str, list[dict]]:
series_dir = ROOT / "contracts" / "openstack" / series
by_service: dict[str, list[dict]] = {}
for api in sorted(series_dir.glob("*/api.json")):
data = json.loads(api.read_text())
service = str(data["service"])
writes = [
op
for op in data.get("operations") or []
if op.get("method") in {"POST", "PUT", "PATCH"}
]
by_service[service] = writes
return by_service
def generate(series: str, out_dir: Path) -> dict[str, int]:
out_dir.mkdir(parents=True, exist_ok=True)
counts: dict[str, int] = {}
# Merge ops across series for max coverage of operation_ids.
merged: dict[str, dict[str, dict]] = defaultdict(dict)
for series_name in ("yoga", "antelope", "caracal", "dalmatian"):
if series and series != "all" and series_name != series:
continue
for service, ops in _load_ops(series_name).items():
for op in ops:
oid = op["operation_id"]
path_key = f"{op['method']} {op['path']}"
merged[service][oid] = op
# also keep path index later
_ = path_key
for service, by_oid in sorted(merged.items()):
operations: dict[str, dict] = {}
by_path: dict[str, dict] = {}
for oid, op in sorted(by_oid.items()):
schema = schema_for_operation(op)
operations[oid] = schema
by_path[f"{op['method']} {op['path']}"] = schema
payload = {
"service": service,
"source": "generated-from-api-ref-catalog",
"operations": operations,
"by_path": by_path,
}
path = out_dir / f"{service}.json"
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
counts[service] = len(operations)
return counts
def coverage_report(series: str) -> int:
from app.openstack.contract_loader import load_series_pack
from app.openstack.request_bodies import clear_request_body_cache, missing_write_schemas
clear_request_body_cache()
packs = load_series_pack(series)
missing = missing_write_schemas(packs)
print(f"series={series} missing={len(missing)}")
for service, method, path, oid in missing[:50]:
print(f" {service} {method} {path} ({oid})")
if len(missing) > 50:
print(f" ... and {len(missing) - 50} more")
return len(missing)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--series", default="all", help="Series to scan or 'all'")
parser.add_argument("--coverage", action="store_true", help="Report missing schemas only")
parser.add_argument(
"--coverage-series",
default=SERIES_DEFAULT,
help="Series for coverage check (default: dalmatian)",
)
args = parser.parse_args()
if args.coverage:
missing = coverage_report(args.coverage_series)
return 1 if missing else 0
series = None if args.series == "all" else args.series
counts = generate(series or "all", OUT_DIR)
total = sum(counts.values())
print(f"Wrote {len(counts)} services, {total} operation schemas → {OUT_DIR}")
for name, count in sorted(counts.items()):
print(f" {name}: {count}")
if args.series == "all":
# Coverage check requires the project venv (Python 3.13 dataclasses).
print("Run coverage with: python tools/os_api_inventory/generate_request_bodies.py --coverage")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""Import requestBody schemas from gtema/openstack-openapi into request_bodies/.
Discovers versioned OpenAPI YAML under ``specs/<service>/`` and merges request
schemas into ``contracts/openstack/request_bodies/<service>.json``.
"""
from __future__ import annotations
import argparse
import json
import re
import urllib.request
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[2]
OUT_DIR = ROOT / "contracts" / "openstack" / "request_bodies"
SERVICE_MAP = {
"compute": "nova",
"network": "neutron",
"identity": "keystone",
"image": "glance",
"block-storage": "cinder",
"load-balancing": "octavia",
"object-store": "swift",
"placement": "placement",
}
API_CONTENTS = (
"https://api.github.com/repos/gtema/openstack-openapi/contents/specs/{svc}?ref=main"
)
def _load_yaml(text: str) -> dict[str, Any]:
try:
import yaml # type: ignore
except ImportError as exc: # pragma: no cover
raise SystemExit("PyYAML is required: pip install pyyaml") from exc
data = yaml.safe_load(text)
if not isinstance(data, dict):
raise ValueError("OpenAPI root must be a mapping")
return data
def _http_json(url: str) -> Any:
req = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json"})
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read().decode())
def _http_text(url: str, *, attempts: int = 3) -> str:
last_error: Exception | None = None
for attempt in range(1, attempts + 1):
try:
with urllib.request.urlopen(url, timeout=180) as resp:
chunks: list[bytes] = []
while True:
block = resp.read(1024 * 1024)
if not block:
break
chunks.append(block)
return b"".join(chunks).decode()
except Exception as exc:
last_error = exc
print(f" download attempt {attempt}/{attempts} failed: {exc}")
if last_error is None:
raise RuntimeError("download failed without error")
raise last_error
def _pick_spec_url(openapi_name: str) -> str:
entries = _http_json(API_CONTENTS.format(svc=openapi_name))
yaml_files = [
item
for item in entries
if item.get("type") == "file" and str(item.get("name", "")).endswith(".yaml")
]
if not yaml_files:
raise FileNotFoundError(f"No OpenAPI YAML under specs/{openapi_name}")
def sort_key(item: dict[str, Any]) -> tuple[int, ...]:
name = str(item["name"])
nums = [int(x) for x in re.findall(r"\d+", name)]
return tuple(nums) if nums else (0,)
# Prefer the highest versioned file (e.g. v2.96.yaml over v2.yaml).
best = sorted(yaml_files, key=sort_key)[-1]
url = best.get("download_url")
if not url:
raise FileNotFoundError(best)
return str(url)
def _resolve_ref(doc: dict[str, Any], node: Any) -> Any:
if not isinstance(node, dict):
return node
ref = node.get("$ref")
if not isinstance(ref, str) or not ref.startswith("#/"):
return {k: _resolve_ref(doc, v) for k, v in node.items() if k != "$ref"}
cursor: Any = doc
for part in ref[2:].split("/"):
part = part.replace("~1", "/").replace("~0", "~")
cursor = cursor[part]
return _resolve_ref(doc, cursor)
def _request_schema(doc: dict[str, Any], operation: dict[str, Any]) -> dict[str, Any] | None:
body = operation.get("requestBody")
if not body:
return None
body = _resolve_ref(doc, body)
content = body.get("content") or {}
for key in ("application/json", "application/openstack-images-v2.1-json-patch"):
if key in content:
media = _resolve_ref(doc, content[key])
schema = media.get("schema")
if schema:
return _resolve_ref(doc, schema)
for media in content.values():
media = _resolve_ref(doc, media)
schema = media.get("schema")
if schema:
return _resolve_ref(doc, schema)
return None
def _normalize_path(path: str) -> str:
parts = [p for p in path.split("/") if p not in {"{project_id}", "{tenant_id}"}]
normalized = "/" + "/".join(p for p in parts if p)
return normalized.replace("//", "/")
def import_service(openapi_name: str, pack_name: str) -> int:
url = _pick_spec_url(openapi_name)
print(f"Fetching {url}")
doc = _load_yaml(_http_text(url))
paths = doc.get("paths") or {}
by_path: dict[str, dict[str, Any]] = {}
for path, item in paths.items():
if not isinstance(item, dict):
continue
norm = _normalize_path(str(path))
for method, operation in item.items():
if method.upper() not in {"POST", "PUT", "PATCH"}:
continue
if not isinstance(operation, dict):
continue
schema = _request_schema(doc, operation)
if not schema:
continue
by_path[f"{method.upper()} {norm}"] = schema
out_path = OUT_DIR / f"{pack_name}.json"
existing: dict[str, Any] = {"service": pack_name, "operations": {}, "by_path": {}}
if out_path.is_file():
existing = json.loads(out_path.read_text())
operations = dict(existing.get("operations") or {})
existing_by_path = dict(existing.get("by_path") or {})
# OpenAPI schemas override generated stubs for matching paths.
existing_by_path.update(by_path)
matched = 0
pack_api = ROOT / "contracts" / "openstack" / "dalmatian" / pack_name / "api.json"
if pack_api.is_file():
pack = json.loads(pack_api.read_text())
for op in pack.get("operations") or []:
if op.get("method") not in {"POST", "PUT", "PATCH"}:
continue
key = f"{op['method']} {op['path']}"
schema = existing_by_path.get(key)
if schema is None:
continue
operations[op["operation_id"]] = schema
matched += 1
payload = {
"service": pack_name,
"source": f"openstack-openapi:{openapi_name}",
"operations": operations,
"by_path": existing_by_path,
}
out_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
print(f" {pack_name}: openapi_paths={len(by_path)} matched_ops={matched}{out_path}")
return matched
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--service",
action="append",
choices=sorted(SERVICE_MAP),
help="OpenAPI service dir (repeatable). Default: all Tier-1",
)
args = parser.parse_args()
selected = args.service or list(SERVICE_MAP)
OUT_DIR.mkdir(parents=True, exist_ok=True)
total = 0
for openapi_name in selected:
total += import_service(openapi_name, SERVICE_MAP[openapi_name])
print(f"Done. matched operation schemas: {total}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

Some files were not shown because too many files have changed in this diff Show More