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

|
||||
|
||||
Walkthrough of drawers and request parameters: [Web UI](web-ui.md).
|
||||
|
||||
## 7. Smoke / conformance
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 56 KiB |
@@ -69,6 +69,21 @@ PYTHONPATH=tools python3 tools/os_api_inventory/generate_packs.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
|
||||
|
||||
PostgreSQL is the system of record. Use `pg_dump` / volume snapshots.
|
||||
|
||||
@@ -33,7 +33,7 @@ curl -X POST http://127.0.0.1:5000/ui/api/openstack/contracts/activate \
|
||||
-d '{"series":"dalmatian"}'
|
||||
```
|
||||
|
||||
Или Web UI → Environment → OpenStack API pack → Activate.
|
||||
Или Web UI → API catalog → выбрать серию → **Apply as runtime**.
|
||||
|
||||
Hot-swap перемонтирует schema-маршруты (`remount_schema_services`) без пересборки
|
||||
образа.
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
| Yoga | 6 | 1060 |
|
||||
|
||||
Более старые серии опускают пути, добавленные позже (`tools/os_api_inventory/series_deltas.py`),
|
||||
и используют более низкие потолки microversion. Примените пакет в Environment drawer для hot-swap.
|
||||
и используют более низкие потолки microversion. Примените пакет в API catalog drawer для hot-swap.
|
||||
|
||||
Surface-complete означает, что каждая операция пакета смонтирована schema-движком
|
||||
(специализированные роутеры по-прежнему выигрывают на пересекающихся stateful-путях).
|
||||
|
||||
@@ -53,7 +53,4 @@
|
||||
|
||||
## Переопределения Web UI
|
||||
|
||||
Environment drawer → **OpenStack API pack**:
|
||||
|
||||
- Активация серии (hot remount)
|
||||
- Переопределение microversion по сервисам
|
||||
API catalog drawer: карточка серии → microversion в карточке → **Apply as runtime**.
|
||||
|
||||
@@ -24,4 +24,4 @@ make seed-demo
|
||||
|
||||
## Microversion отклонён
|
||||
|
||||
Понизьте запрошенную compute microversion или сбросьте переопределения в Web UI.
|
||||
Понизьте запрошенную compute microversion или примените более раннюю серию в API catalog.
|
||||
|
||||
@@ -95,8 +95,12 @@ curl -sH "X-Auth-Token: $TOKEN" http://127.0.0.1:9696/v2.0/networks
|
||||
|
||||
## 6. Откройте Web UI
|
||||
|
||||
[http://localhost:5000/](http://localhost:5000/) — консоль, Environment drawer
|
||||
(серия OpenStack pack + microversions), Data drawer (загрузка/выгрузка demo cloud).
|
||||
[http://localhost:5000/](http://localhost:5000/) — консоль, API catalog
|
||||
(серия OpenStack pack), Data drawer (загрузка/выгрузка demo cloud).
|
||||
|
||||

|
||||
|
||||
Обзор drawers и параметров запроса: [Web UI](web-ui.md).
|
||||
|
||||
## 7. Smoke / conformance
|
||||
|
||||
|
||||
@@ -69,6 +69,21 @@ PYTHONPATH=tools python3 tools/os_api_inventory/generate_packs.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.
|
||||
|
||||
@@ -10,15 +10,70 @@
|
||||
| `/docs` | OpenAPI (simulator) |
|
||||
| `/ui/api/…` | UI JSON APIs |
|
||||
|
||||

|
||||
|
||||
## Endpoints drawer
|
||||
|
||||
Обзор поверхности пака по сервисам (например Adjutant). У каждого path —
|
||||
поддерживаемые HTTP-методы.
|
||||
|
||||

|
||||
|
||||
## Отправка запросов
|
||||
|
||||
Выберите verb + path и нажмите **Send**. Успешные ответы попадают в
|
||||
**RESPONSE**.
|
||||
|
||||

|
||||
|
||||
### Request parameters
|
||||
|
||||
Для `POST` / `PUT` / `PATCH` в **Request parameters** показаны поля схемы
|
||||
(dotted-имена для вложенных OpenStack envelope), типы, optional и примеры.
|
||||
JSON Request body собирается из этих полей.
|
||||
|
||||

|
||||
|
||||
## Authentication drawer
|
||||
|
||||
Вход через лабораторный Keystone (`admin` / `secret`, проекты вроде `admin`
|
||||
или `demo`). Можно вставить готовый `X-Auth-Token`.
|
||||
|
||||

|
||||
|
||||
## Environment drawer
|
||||
|
||||
- **OpenStack API pack** — список серий, активация пакета, переопределения microversion
|
||||
- Apply немедленно перемонтирует schema-маршруты
|
||||
- Runtime / catalog / активная **microversion**, плюс живой инвентарь облака
|
||||
(servers, nets, volumes…)
|
||||
|
||||

|
||||
|
||||
## API catalog drawer
|
||||
|
||||
- Выбор карточки серии (`os · yoga` …), microversion в карточке, затем
|
||||
**Apply as runtime** (выбор сохраняется после перезагрузки)
|
||||
|
||||

|
||||
|
||||
## Data drawer
|
||||
|
||||
- **Load demo cloud** — `POST /ui/api/demo/load` → `seed_openstack_demo`
|
||||
- **Unload / minimal** — сброс к minimal seed
|
||||
- **Load demo cloud** — кластеры small / large / big
|
||||
- **Reset to minimal** — минимальный lab seed
|
||||
|
||||

|
||||
|
||||
## History drawer
|
||||
|
||||
Недавние вызовы консоли: method, URL и status.
|
||||
|
||||

|
||||
|
||||
## Help · Compatibility
|
||||
|
||||
Покрытие поверхности пака для активной серии (declared / implemented,
|
||||
сервисы, смесь verb).
|
||||
|
||||

|
||||
|
||||
## Брендинг
|
||||
|
||||
|
||||
@@ -10,15 +10,70 @@ Console is served from the Keystone/UI port (**5000** on the gateway).
|
||||
| `/docs` | OpenAPI (simulator) |
|
||||
| `/ui/api/…` | UI JSON APIs |
|
||||
|
||||

|
||||
|
||||
## Endpoints drawer
|
||||
|
||||
Browse the pack surface by service (for example Adjutant). Each path shows the
|
||||
supported verbs.
|
||||
|
||||

|
||||
|
||||
## Sending requests
|
||||
|
||||
Pick a verb + path, then **Send**. Successful calls show status and JSON in
|
||||
**RESPONSE**.
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
## Authentication drawer
|
||||
|
||||
Sign in with lab Keystone users (`admin` / `secret`, projects such as `admin`
|
||||
or `demo`). Optional paste of an existing `X-Auth-Token`.
|
||||
|
||||

|
||||
|
||||
## Environment drawer
|
||||
|
||||
- **OpenStack API pack** — list series, activate pack, set microversion overrides
|
||||
- Apply remounts schema routes immediately
|
||||
- Runtime / catalog / active **microversion**, plus live cloud inventory
|
||||
(servers, nets, volumes…)
|
||||
|
||||

|
||||
|
||||
## 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)
|
||||
|
||||

|
||||
|
||||
## Data drawer
|
||||
|
||||
- **Load demo cloud** — `POST /ui/api/demo/load` → `seed_openstack_demo`
|
||||
- **Unload / minimal** — reset to minimal seed
|
||||
- **Load demo cloud** — sized clusters (small / large / big)
|
||||
- **Reset to minimal** — minimal lab seed
|
||||
|
||||

|
||||
|
||||
## History drawer
|
||||
|
||||
Recent console calls with method, URL, and status.
|
||||
|
||||

|
||||
|
||||
## Help · Compatibility
|
||||
|
||||
Pack surface coverage for the active series (declared / implemented ops,
|
||||
services, verb mix).
|
||||
|
||||

|
||||
|
||||
## Branding
|
||||
|
||||
|
||||
@@ -2,8 +2,16 @@
|
||||
|
||||
# Pulumi OpenStack tests (`pulumi-tests`)
|
||||
|
||||
Coverage lab that **maximises `pulumi_openstack`**, then probes remaining pack
|
||||
operations over HTTP with **non-empty body checks** and **full method coverage**.
|
||||
**100% coverage = HTTP contract matrix** (every series-pack operation + synthetic
|
||||
`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
|
||||
|
||||
@@ -14,8 +22,8 @@ make pulumi-tests
|
||||
# or
|
||||
cd pulumi-tests
|
||||
make up && make build
|
||||
make test-pulumi-smoke # fast: collection GET only
|
||||
make test-pulumi # full: all pack ops × all HTTP methods
|
||||
make test-pulumi-smoke # fast: collection GET + HEAD
|
||||
make test-pulumi # full Layer A matrix × all series
|
||||
open reports/pulumi-report.html
|
||||
```
|
||||
|
||||
@@ -23,28 +31,33 @@ open reports/pulumi-report.html
|
||||
|
||||
| Target | Mode | What is exercised |
|
||||
|---|---|---|
|
||||
| `make test-pulumi-smoke` | Smoke (`TEST_SMOKE=1`) | `pulumi_openstack` + **collection GET** 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 test-pulumi-smoke` | Smoke (`TEST_SMOKE=1`) | Layer B + **collection GET + HEAD** nonempty checks (fast) |
|
||||
| `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)
|
||||
|
||||
1. Activate OpenStack series pack
|
||||
2. **`pulumi up`** `programs/os_coverage` via Automation API — creates/looks up
|
||||
resources with `pulumi_openstack` (identity, images, compute, networking,
|
||||
blockstorage, objectstorage, dns, orchestration)
|
||||
3. Assert **every stack export is non-empty**
|
||||
4. HTTP-probe pack operations (smoke: collection GET; full: all methods lifecycle)
|
||||
5. Assert coverage completeness + nonempty JSON on successful body responses
|
||||
2. **`pulumi up`** `programs/os_coverage` (Layer B — provider smoke)
|
||||
3. Assert stack exports are non-empty (Layer B)
|
||||
4. HTTP contract matrix on **catalog ports** (Keystone token → Nova `:8774`, Neutron `:9696`, …)
|
||||
5. Assert `probed == declared + HEAD`, `critical=0`, nonempty JSON on success bodies
|
||||
6. `pulumi destroy`
|
||||
7. Write `pulumi-report.html` + `pulumi-junit.xml`
|
||||
7. Write `pulumi-report.html` + `pulumi-junit.xml` + verb histogram (incl. HEAD)
|
||||
|
||||
## Reports
|
||||
|
||||
| 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/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.
|
||||
|
||||
@@ -2,8 +2,16 @@
|
||||
|
||||
# Тесты Pulumi OpenStack (`pulumi-tests`)
|
||||
|
||||
Лаборатория покрытия: **максимально `pulumi_openstack`**, затем HTTP-probe
|
||||
остальных pack-операций с проверкой **непустых тел** и **полным покрытием методов**.
|
||||
**100% покрытия = HTTP contract matrix** (каждая операция series-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
|
||||
make up && make build
|
||||
make test-pulumi-smoke # быстро: только collection GET
|
||||
make test-pulumi # полный: все ручки пака × все HTTP-методы
|
||||
make test-pulumi-smoke # быстро: collection GET + HEAD
|
||||
make test-pulumi # полная Layer A matrix × все серии
|
||||
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 pulumi-tests` / `make test-pulumi` | Полный lifecycle | `pulumi_openstack` + **все операции пака × GET/POST/PUT/PATCH/DELETE**, assert полноты (`total == размер пака`), nonempty тел успешных ответов (DELETE/204 могут быть пустыми) |
|
||||
| `make test-pulumi-smoke` | Smoke (`TEST_SMOKE=1`) | Layer B + **collection GET + HEAD** с nonempty (быстро) |
|
||||
| `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)
|
||||
|
||||
1. Активация pack серии OpenStack
|
||||
2. **`pulumi up`** программы `programs/os_coverage` через Automation API —
|
||||
ресурсы через `pulumi_openstack` (identity, images, compute, networking,
|
||||
blockstorage, objectstorage, dns, orchestration)
|
||||
3. Проверка: **каждый export стека непустой**
|
||||
4. HTTP-probe pack-операций (smoke: collection GET; полный: lifecycle всех методов)
|
||||
5. Assert полноты покрытия + nonempty JSON у успешных ответов с телом
|
||||
2. **`pulumi up`** `programs/os_coverage` (Layer B — provider smoke)
|
||||
3. Проверка непустых export стека (Layer B)
|
||||
4. HTTP contract matrix на **портах каталога** (токен Keystone → Nova `:8774`, Neutron `:9696`, …)
|
||||
5. Assert `probed == declared + HEAD`, `critical=0`, nonempty JSON
|
||||
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/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`.
|
||||
|
||||
@@ -132,8 +132,8 @@ services:
|
||||
- ../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.key:/etc/nginx/tls/server.key:ro
|
||||
ports:
|
||||
- "127.0.0.1:15000:5000"
|
||||
# No host port publish — pulumi-runner reaches api-gateway on the lab
|
||||
# network (avoids colliding with `make up-local` on :15000).
|
||||
|
||||
pulumi-runner:
|
||||
build:
|
||||
|
||||
@@ -9,19 +9,28 @@ from typing import Any
|
||||
|
||||
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"})
|
||||
_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:
|
||||
return sum(len(p.operations) for p in packs.values())
|
||||
total = 0
|
||||
declared, heads = count_declared_ops(packs)
|
||||
return declared + heads, declared, heads
|
||||
|
||||
declared = 0
|
||||
heads = 0
|
||||
for pack in packs.values():
|
||||
for op in pack.operations:
|
||||
if op.method == "GET" and "{" not in op.path:
|
||||
total += 1
|
||||
return total
|
||||
declared += 1
|
||||
heads += 1
|
||||
return declared + heads, declared, heads
|
||||
|
||||
|
||||
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)
|
||||
if method:
|
||||
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]]:
|
||||
"""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]] = []
|
||||
for r in report.results:
|
||||
if not r.succeeded:
|
||||
continue
|
||||
if r.method == "DELETE" or r.status in {202, 204}:
|
||||
if r.method in {"DELETE", "HEAD"} or r.status in {202, 204}:
|
||||
continue
|
||||
if r.method not in _BODY_METHODS:
|
||||
continue
|
||||
@@ -74,6 +83,8 @@ def _nonempty_smoke_collections(
|
||||
fill_path,
|
||||
http_request,
|
||||
issue_token,
|
||||
microversion_headers,
|
||||
service_base_url,
|
||||
_seed_context,
|
||||
)
|
||||
|
||||
@@ -96,8 +107,14 @@ def _nonempty_smoke_collections(
|
||||
"account": project_id,
|
||||
},
|
||||
)
|
||||
url = f"{host.rstrip('/')}{path}"
|
||||
status, payload = http_request(op.method, url, token=token, service=pack.name)
|
||||
url = f"{service_base_url(host, pack.name, port=pack.port)}{path}"
|
||||
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:
|
||||
continue
|
||||
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)
|
||||
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)
|
||||
coverage_incomplete = len(report.results) != expected_ops
|
||||
|
||||
@@ -168,23 +185,31 @@ def probe_pack_operations(
|
||||
"method": "*",
|
||||
"path": "*",
|
||||
"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,
|
||||
"nonempty": True,
|
||||
}
|
||||
)
|
||||
|
||||
critical = len(coverage_failures) + len(probe_failures) + len(nonempty_failures)
|
||||
return {
|
||||
"series": series,
|
||||
"host": host,
|
||||
"mode": report.mode,
|
||||
"total": len(report.results),
|
||||
"expected_ops": expected_ops,
|
||||
"declared_ops": declared_ops,
|
||||
"head_ops": head_ops,
|
||||
"coverage_incomplete": coverage_incomplete,
|
||||
"methods": methods,
|
||||
"ok_count": len(report.results) - len(report.failures),
|
||||
"fail_count": len(report.failures) + (1 if coverage_incomplete else 0),
|
||||
"nonempty_fail_count": len(nonempty_failures),
|
||||
"critical": critical,
|
||||
"results": [
|
||||
{
|
||||
"service": r.service,
|
||||
|
||||
@@ -12,7 +12,9 @@ SERIES_ORDER = ("yoga", "antelope", "caracal", "dalmatian")
|
||||
|
||||
|
||||
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:
|
||||
@@ -33,8 +35,8 @@ def render_html(summary: dict[str, Any], series_reports: list[dict[str, Any]]) -
|
||||
</div>
|
||||
<div class="stats">
|
||||
<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>http total={http.get("total", 0)}/{http.get("expected_ops", "?")}</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", "?")} (declared={http.get("declared_ops", "?")}+HEAD={http.get("head_ops", "?")})</span>
|
||||
</div>
|
||||
<div class="stats muted">
|
||||
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>
|
||||
<header>
|
||||
<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>
|
||||
<main>
|
||||
<div class="summary">
|
||||
|
||||
@@ -233,10 +233,20 @@ def main() -> int:
|
||||
"total": 0,
|
||||
"expected_ops": 0,
|
||||
"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,
|
||||
"fail_count": 0,
|
||||
"nonempty_fail_count": 0,
|
||||
"critical": 0,
|
||||
"declared_ops": 0,
|
||||
"head_ops": 0,
|
||||
"results": [],
|
||||
"failures": [],
|
||||
}
|
||||
@@ -245,12 +255,15 @@ def main() -> int:
|
||||
http = run_http_coverage(series, collections_only=collections_only)
|
||||
methods = http.get("methods") or {}
|
||||
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(
|
||||
f"{series}: http ok={http.get('ok_count')} fail={http.get('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"(declared={http.get('declared_ops')}+HEAD={http.get('head_ops')}) "
|
||||
f"coverage_incomplete={http.get('coverage_incomplete')} "
|
||||
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))
|
||||
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(
|
||||
1 for r in series_reports if r["http"].get("coverage_incomplete")
|
||||
),
|
||||
"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)
|
||||
(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))
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -21,14 +21,14 @@
|
||||
<body>
|
||||
<header>
|
||||
<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>
|
||||
<main>
|
||||
<div class="summary">
|
||||
<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="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>
|
||||
<h2>Series</h2>
|
||||
@@ -41,12 +41,12 @@
|
||||
<span class="fail">empty exports=0</span>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<span class="ok">http ok=1060</span>
|
||||
<span class="fail">http fail=0 nonempty_fail=0</span>
|
||||
<span>http total=1060/1060</span>
|
||||
<span class="ok">http ok=1464</span>
|
||||
<span class="fail">http fail=0 nonempty_fail=0 critical=0</span>
|
||||
<span>http total=1464/1464 (declared=1060+HEAD=404)</span>
|
||||
</div>
|
||||
<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>
|
||||
@@ -58,12 +58,12 @@
|
||||
<span class="fail">empty exports=0</span>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<span class="ok">http ok=1108</span>
|
||||
<span class="fail">http fail=0 nonempty_fail=0</span>
|
||||
<span>http total=1108/1108</span>
|
||||
<span class="ok">http ok=1530</span>
|
||||
<span class="fail">http fail=0 nonempty_fail=0 critical=0</span>
|
||||
<span>http total=1530/1530 (declared=1108+HEAD=422)</span>
|
||||
</div>
|
||||
<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>
|
||||
@@ -75,12 +75,12 @@
|
||||
<span class="fail">empty exports=0</span>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<span class="ok">http ok=1196</span>
|
||||
<span class="fail">http fail=0 nonempty_fail=0</span>
|
||||
<span>http total=1196/1196</span>
|
||||
<span class="ok">http ok=1649</span>
|
||||
<span class="fail">http fail=0 nonempty_fail=0 critical=0</span>
|
||||
<span>http total=1649/1649 (declared=1196+HEAD=453)</span>
|
||||
</div>
|
||||
<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>
|
||||
@@ -92,12 +92,12 @@
|
||||
<span class="fail">empty exports=0</span>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<span class="ok">http ok=1357</span>
|
||||
<span class="fail">http fail=0 nonempty_fail=0</span>
|
||||
<span>http total=1357/1357</span>
|
||||
<span class="ok">http ok=1871</span>
|
||||
<span class="fail">http fail=0 nonempty_fail=0 critical=0</span>
|
||||
<span>http total=1871/1871 (declared=1357+HEAD=514)</span>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -2,8 +2,14 @@
|
||||
"series_count": 4,
|
||||
"pulumi_ok": 4,
|
||||
"pulumi_fail": 0,
|
||||
"http_ok": 4721,
|
||||
"http_ok": 6514,
|
||||
"http_fail": 0,
|
||||
"http_critical": 0,
|
||||
"http_declared": 4721,
|
||||
"http_head": 1793,
|
||||
"http_total": 6514,
|
||||
"http_expected": 6514,
|
||||
"coverage_incomplete": 0,
|
||||
"collections_only": false
|
||||
"collections_only": false,
|
||||
"definition": "100% = HTTP contract matrix (pack ops + synthetic HEAD), not pulumi_openstack resource count"
|
||||
}
|
||||
|
||||
@@ -49,6 +49,8 @@ select = ["E", "F", "I", "UP", "B", "ASYNC", "S", "RUF"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/**/*.py" = ["S101", "S105"]
|
||||
"tools/os_api_inventory/request_body_catalog.py" = ["E501"]
|
||||
"tools/os_api_inventory/import_openapi_bodies.py" = ["S310"]
|
||||
|
||||
[tool.mypy]
|
||||
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)
|
||||
@@ -8,10 +8,13 @@ import asyncpg
|
||||
import pytest
|
||||
|
||||
from app.openstack.demo_cloud import (
|
||||
DEMO_CLUSTER_SIZES,
|
||||
DEMO_PROFILE,
|
||||
DEMO_SERVER_COUNT,
|
||||
clear_openstack_state,
|
||||
demo_profile_name,
|
||||
is_demo_profile,
|
||||
openstack_demo_summary,
|
||||
resolve_demo_size,
|
||||
seed_openstack_demo,
|
||||
)
|
||||
from app.openstack.seed import seed_openstack
|
||||
@@ -41,15 +44,35 @@ async def conn():
|
||||
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:
|
||||
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)
|
||||
small = DEMO_CLUSTER_SIZES["small"]
|
||||
assert summary["loaded"] is True
|
||||
assert summary["servers"] == DEMO_SERVER_COUNT
|
||||
assert summary["hypervisors"] == 16
|
||||
assert summary["servers"] == small.servers
|
||||
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["volumes"] == 600
|
||||
assert summary["profile"] == DEMO_PROFILE
|
||||
assert len(summary["sizes"]) == 3
|
||||
|
||||
await clear_openstack_state(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["profile"] == "minimal"
|
||||
|
||||
# Restore demo so a shared lab DB stays usable after the test.
|
||||
await seed_openstack_demo(conn)
|
||||
# Restore large so a shared lab DB stays usable after the test.
|
||||
await seed_openstack_demo(conn, size="large")
|
||||
summary = await openstack_demo_summary(conn)
|
||||
large = DEMO_CLUSTER_SIZES["large"]
|
||||
assert summary["loaded"] is True
|
||||
assert summary["servers"] == DEMO_SERVER_COUNT
|
||||
assert summary["servers"] == large.servers
|
||||
assert summary["hypervisors"] == large.hypervisors
|
||||
|
||||
@@ -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"}}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -60,9 +60,9 @@ def _crud(
|
||||
) -> list[dict[str, Any]]:
|
||||
"""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
|
||||
if key.endswith("ies"):
|
||||
singular = key[:-3] + "y"
|
||||
from app.openstack.singular import singular as _singularize
|
||||
|
||||
singular = _singularize(key)
|
||||
ops: list[dict[str, Any]] = [
|
||||
{
|
||||
"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())
|
||||