Prepare 0.1.0 for lab release: durable handlers, HTTP Compose, CI, and pulumi-tests.
- Harden DB-backed handlers and seed profiles; align client wire shapes for cluster resources, QEMU config, and node SSL fields - Serve plain HTTP on Compose :8006; keep TLS optional (--profile tls) and terminate HTTPS at Kubernetes Ingress - Add pulumi-tests (full contract surface majors 6–9 + BPG lifecycle) and make pulumi-tests - Ship bilingual docs, CHANGELOG, SECURITY, CONTRIBUTING, and GitHub Actions (make ci + Compose/Helm validation)
This commit is contained in:
@@ -25,7 +25,7 @@ def wait_task(proxmox: Any, upid: str) -> dict[str, object]:
|
||||
def test_proxmoxer_read_and_qemu_task_flow() -> None:
|
||||
proxmox = ProxmoxAPI(
|
||||
os.environ["PROXMOXER_HOST"],
|
||||
port=int(os.getenv("PROXMOXER_PORT", "8007")),
|
||||
port=int(os.getenv("PROXMOXER_PORT", "8006")),
|
||||
user="root@pam",
|
||||
password=os.getenv("PROXMOXER_PASSWORD", "secret"),
|
||||
verify_ssl=False,
|
||||
@@ -33,11 +33,11 @@ def test_proxmoxer_read_and_qemu_task_flow() -> None:
|
||||
|
||||
assert proxmox.version.get()["version"] == "9.2.3"
|
||||
assert any(node["node"] == "pve1" for node in proxmox.nodes.get())
|
||||
assert any(vm["vmid"] == 101 for vm in proxmox.nodes("pve1").qemu.get())
|
||||
assert any(vm["vmid"] == 100 for vm in proxmox.nodes("pve1").qemu.get())
|
||||
|
||||
token_api = ProxmoxAPI(
|
||||
os.environ["PROXMOXER_HOST"],
|
||||
port=int(os.getenv("PROXMOXER_PORT", "8007")),
|
||||
port=int(os.getenv("PROXMOXER_PORT", "8006")),
|
||||
user="root@pam",
|
||||
token_name=os.getenv("PROXMOXER_TOKEN_NAME", "automation"),
|
||||
token_value=os.getenv("PROXMOXER_TOKEN_SECRET", "automation-secret"),
|
||||
@@ -47,7 +47,7 @@ def test_proxmoxer_read_and_qemu_task_flow() -> None:
|
||||
|
||||
readonly_api = ProxmoxAPI(
|
||||
os.environ["PROXMOXER_HOST"],
|
||||
port=int(os.getenv("PROXMOXER_PORT", "8007")),
|
||||
port=int(os.getenv("PROXMOXER_PORT", "8006")),
|
||||
user="auditor@pve",
|
||||
token_name=os.getenv("PROXMOXER_READONLY_TOKEN_NAME", "readonly"),
|
||||
token_value=os.getenv("PROXMOXER_READONLY_TOKEN_SECRET", "readonly-secret"),
|
||||
@@ -55,9 +55,9 @@ def test_proxmoxer_read_and_qemu_task_flow() -> None:
|
||||
)
|
||||
assert readonly_api.nodes.get()
|
||||
assert readonly_api.nodes("pve1").status.get()["status"] == "online"
|
||||
assert readonly_api.nodes("pve1").qemu("101").config.get()["vmid"] == 101
|
||||
assert readonly_api.nodes("pve1").qemu("100").config.get()["vmid"] == 100
|
||||
with pytest.raises(ResourceException) as denied:
|
||||
readonly_api.nodes("pve1").qemu("101").status.start.post()
|
||||
readonly_api.nodes("pve1").qemu("100").status.start.post()
|
||||
assert denied.value.status_code == 403
|
||||
|
||||
token_endpoint = proxmox.access.users("root@pam").token("ephemeral")
|
||||
@@ -65,7 +65,7 @@ def test_proxmoxer_read_and_qemu_task_flow() -> None:
|
||||
assert created["full-tokenid"] == "root@pam!ephemeral"
|
||||
ephemeral = ProxmoxAPI(
|
||||
os.environ["PROXMOXER_HOST"],
|
||||
port=int(os.getenv("PROXMOXER_PORT", "8007")),
|
||||
port=int(os.getenv("PROXMOXER_PORT", "8006")),
|
||||
user="root@pam",
|
||||
token_name=os.getenv("PROXMOXER_EPHEMERAL_TOKEN_NAME", "ephemeral"),
|
||||
token_value=created["value"],
|
||||
@@ -82,13 +82,13 @@ def test_proxmoxer_read_and_qemu_task_flow() -> None:
|
||||
|
||||
storage_api = ProxmoxAPI(
|
||||
os.environ["PROXMOXER_HOST"],
|
||||
port=int(os.getenv("PROXMOXER_PORT", "8007")),
|
||||
port=int(os.getenv("PROXMOXER_PORT", "8006")),
|
||||
user="storage@pve",
|
||||
token_name=os.getenv("PROXMOXER_STORAGE_TOKEN_NAME", "storage"),
|
||||
token_value=os.getenv("PROXMOXER_STORAGE_TOKEN_SECRET", "storage-secret"),
|
||||
verify_ssl=False,
|
||||
)
|
||||
for vmid in ("101", "999999"):
|
||||
for vmid in ("100", "999999"):
|
||||
with pytest.raises(ResourceException) as hidden:
|
||||
storage_api.nodes("pve1").qemu(vmid).config.get()
|
||||
assert hidden.value.status_code == 403
|
||||
@@ -99,7 +99,7 @@ def test_proxmoxer_read_and_qemu_task_flow() -> None:
|
||||
cores=2,
|
||||
memory=1024,
|
||||
agent=1,
|
||||
scsi0="local-lvm:vm-150-disk-0,size=8G",
|
||||
scsi0="local-pve1:vm-150-disk-0,size=8G",
|
||||
)
|
||||
with pytest.raises(ResourceException) as duplicate_create:
|
||||
proxmox.nodes("pve1").qemu.post(vmid=150, name="duplicate")
|
||||
@@ -118,9 +118,9 @@ def test_proxmoxer_read_and_qemu_task_flow() -> None:
|
||||
disk_api = proxmox.nodes("pve1").qemu("150")
|
||||
assert disk_api.resize.put(disk="scsi0", size="+2G") is None
|
||||
assert "size=10G" in disk_api.config.get()["scsi0"]
|
||||
move_upid = disk_api.move_disk.post(disk="scsi0", storage="local")
|
||||
move_upid = disk_api.move_disk.post(disk="scsi0", storage="shared")
|
||||
assert wait_task(proxmox, move_upid)["exitstatus"] == "OK"
|
||||
assert disk_api.config.get()["scsi0"].startswith("local:")
|
||||
assert disk_api.config.get()["scsi0"].startswith("shared:")
|
||||
assert disk_api.pending.get() == []
|
||||
assert wait_task(proxmox, disk_api.status.start.post())["exitstatus"] == "OK"
|
||||
assert disk_api.agent.ping.post()["result"] == {}
|
||||
@@ -169,13 +169,13 @@ def test_proxmoxer_read_and_qemu_task_flow() -> None:
|
||||
if os.getenv("PROXMOXER_MUTATION_TEST") == "1":
|
||||
operator_api = ProxmoxAPI(
|
||||
os.environ["PROXMOXER_HOST"],
|
||||
port=int(os.getenv("PROXMOXER_PORT", "8007")),
|
||||
port=int(os.getenv("PROXMOXER_PORT", "8006")),
|
||||
user="operator@pve",
|
||||
token_name=os.getenv("PROXMOXER_OPERATOR_TOKEN_NAME", "operator"),
|
||||
token_value=os.getenv("PROXMOXER_OPERATOR_TOKEN_SECRET", "operator-secret"),
|
||||
verify_ssl=False,
|
||||
)
|
||||
status_resource = operator_api.nodes("pve1").qemu("101").status
|
||||
status_resource = operator_api.nodes("pve1").qemu("100").status
|
||||
|
||||
def run(operation: str, expected: str) -> None:
|
||||
upid = status_resource(operation).post()
|
||||
|
||||
@@ -21,7 +21,9 @@ class FakePool:
|
||||
"name": "root@pam",
|
||||
"password_hash": hash_secret("secret", salt=b"pve-simulator-v1"),
|
||||
}
|
||||
if "FROM nodes" in sql and args[0] == "pve1":
|
||||
if "FROM nodes" in sql and "metadata" in sql and args and args[0] == "pve1":
|
||||
return {"metadata": '{"ops":{"status":{"uptime":100}}}'}
|
||||
if "FROM nodes" in sql and args and args[0] == "pve1":
|
||||
return {"name": "pve1", "status": "online"}
|
||||
if "FROM resources r" in sql and args == ("pve1", "100"):
|
||||
if "SELECT r.id" in sql:
|
||||
@@ -196,7 +198,12 @@ async def test_core_login_and_read_endpoints(
|
||||
assert version.json()["data"]["release"] == "test"
|
||||
assert nodes.json()["data"][0]["node"] == "pve1"
|
||||
assert status.json()["data"]["status"] == "online"
|
||||
assert resources.json()["data"][0]["type"] == "qemu"
|
||||
resource_types = {item["type"] for item in resources.json()["data"]}
|
||||
assert "qemu" in resource_types
|
||||
qemu_resources = [item for item in resources.json()["data"] if item["type"] == "qemu"]
|
||||
assert qemu_resources
|
||||
assert isinstance(qemu_resources[0]["cpu"], int | float)
|
||||
assert qemu_resources[0]["vmid"] == 100
|
||||
assert qemu.json()["data"][0]["vmid"] == 100
|
||||
assert config.json()["data"]["name"] == "demo"
|
||||
assert start.json()["data"].startswith("UPID:pve1:")
|
||||
|
||||
@@ -5,11 +5,15 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi import Request
|
||||
from fastapi import FastAPI, Request
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.api.registry import HandlerRegistry, RouteCollisionError
|
||||
from app.api.registry import (
|
||||
HandlerRegistry,
|
||||
RouteCollisionError,
|
||||
register_unbound_handler_routes,
|
||||
)
|
||||
from app.config import Settings
|
||||
from app.contracts.model import Method, PathContract, Schema, Snapshot
|
||||
from app.main import create_app
|
||||
@@ -97,3 +101,21 @@ def test_duplicate_semantic_handlers_are_rejected() -> None:
|
||||
handlers.register("/version", "GET", handler)
|
||||
with pytest.raises(RouteCollisionError, match="duplicate"):
|
||||
handlers.register("/version", "GET", handler)
|
||||
|
||||
|
||||
def test_unbound_handler_routes_are_mounted() -> None:
|
||||
app = FastAPI()
|
||||
handlers = HandlerRegistry()
|
||||
|
||||
async def firewall_log(_request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
return [{"n": 0, "msg": "from-db"}]
|
||||
|
||||
handlers.register("/cluster/firewall/log", "GET", firewall_log)
|
||||
register_unbound_handler_routes(app, handlers, "error")
|
||||
|
||||
routes = {
|
||||
(getattr(route, "path", None), tuple(sorted(getattr(route, "methods", set()) or set())))
|
||||
for route in app.routes
|
||||
}
|
||||
assert ("/api2/json/cluster/firewall/log", ("GET",)) in routes
|
||||
assert ("/api2/extjs/cluster/firewall/log", ("GET",)) in routes
|
||||
|
||||
@@ -66,7 +66,11 @@ class HandlerPool:
|
||||
async def fetchrow(self, sql: str, *args: object) -> dict[str, object] | None:
|
||||
del args
|
||||
if "FROM nodes WHERE name" in sql:
|
||||
return {"name": "pve01", "status": "online"} if self.node_exists else None
|
||||
if not self.node_exists:
|
||||
return None
|
||||
if "metadata" in sql:
|
||||
return {"metadata": "{}"}
|
||||
return {"name": "pve01", "status": "online"}
|
||||
if "FROM clusters" in sql:
|
||||
return {"metadata": '{"options":{"keyboard":"de-ch"}}'}
|
||||
if "FROM storages" in sql:
|
||||
@@ -156,7 +160,7 @@ async def test_storage_and_ceph_handlers() -> None:
|
||||
)
|
||||
assert osds[0]["status"] == "up"
|
||||
ceph_status = await _call(registry.get("/cluster/ceph/status", "GET"), {})
|
||||
assert ceph_status["osdmap"]["num_osds"] == 300
|
||||
assert ceph_status["osdmap"]["num_osds"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Persistent QEMU CRUD semantic handler tests."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, cast
|
||||
@@ -66,9 +67,20 @@ class QemuPool:
|
||||
return {"state": '{"status":"stopped"}', "config": '{"name":"vm"}'}
|
||||
if "SELECT r.id, r.state" in sql:
|
||||
status = "running" if self.running else "stopped"
|
||||
agent = {
|
||||
"enabled": True,
|
||||
"results": {
|
||||
"info": {"version": "9.2.0"},
|
||||
"get-osinfo": {"machine": "x86_64", "name": "l26"},
|
||||
"get-host-name": {"host-name": "vm"},
|
||||
"network-get-interfaces": [{"name": "eth0"}],
|
||||
"get-time": {"seconds": 1_700_000_000},
|
||||
"ping": {},
|
||||
},
|
||||
}
|
||||
return {
|
||||
"id": self.resource_id,
|
||||
"state": f'{{"status":"{status}"}}',
|
||||
"state": json.dumps({"status": status, "name": "vm", "agent": agent}),
|
||||
"config": ('{"agent":1,"name":"vm","scsi0":"local-lvm:vm-150-disk-0,size=8G"}'),
|
||||
}
|
||||
if "SELECT r.id, r.state, v.config" in sql:
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
"""Deterministic seed profile tests."""
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from app.simulation.seed import (
|
||||
build_profile,
|
||||
clear_simulation_state,
|
||||
cluster_domain_metadata,
|
||||
default_node_ops_for_seed,
|
||||
enrich_guest_state,
|
||||
enrich_storage_state,
|
||||
large_profile,
|
||||
small_profile,
|
||||
stable_id,
|
||||
@@ -101,6 +107,70 @@ def test_stable_ids_are_namespaced_and_repeatable() -> None:
|
||||
assert stable_id("qemu:100") != stable_id("qemu:101")
|
||||
|
||||
|
||||
def test_cluster_domain_metadata_seeds_list_domains() -> None:
|
||||
meta = cast(dict[str, Any], cluster_domain_metadata(small_profile()))
|
||||
assert meta["firewall"]["scopes"]["cluster"]["rules"]
|
||||
assert meta["firewall"]["scopes"]["cluster"]["aliases"]
|
||||
assert meta["firewall"]["scopes"]["cluster"]["ipset"]
|
||||
assert meta["firewall"]["scopes"]["cluster"]["groups"]
|
||||
assert meta["firewall"]["scopes"]["node:pve01"]["rules"]
|
||||
assert meta["firewall"]["scopes"]["qemu:pve01:100"]["rules"]
|
||||
assert meta["firewall"]["scopes"]["lxc:pve01:200"]["rules"]
|
||||
assert meta["firewall_macros"]
|
||||
assert meta["sdn"]["zones"]
|
||||
assert meta["sdn"]["vnets"]
|
||||
assert meta["sdn"]["controllers"]
|
||||
assert meta["sdn"]["ipams"]
|
||||
assert meta["sdn"]["dns"]
|
||||
assert meta["notifications"]["endpoints"]["smtp"]
|
||||
assert meta["notifications"]["endpoints"]["gotify"]
|
||||
assert meta["notifications"]["matchers"]
|
||||
assert meta["notifications"]["matcher_fields"]
|
||||
assert meta["acme"]["accounts"]
|
||||
assert meta["acme"]["plugins"]
|
||||
assert meta["acme"]["directories"]
|
||||
assert meta["acme"]["challenge_schema"]
|
||||
assert meta["mapping"]["pci"]
|
||||
assert meta["mapping"]["usb"]
|
||||
assert meta["mapping"]["dir"]
|
||||
assert meta["replication"]
|
||||
assert meta["metrics"]["servers"]
|
||||
assert meta["ha_groups"]
|
||||
assert meta["ceph"]["pools"]
|
||||
assert meta["ceph"]["flags"]
|
||||
assert meta["ceph"]["version"]
|
||||
assert meta["qemu_cpu_flags"]
|
||||
assert meta["metrics"]["export_data"]
|
||||
assert meta["jobs"]["schedule_analyze_results"]
|
||||
assert meta["replication"][0]["log"]
|
||||
ops = cast(dict[str, Any], default_node_ops_for_seed("pve01"))
|
||||
assert ops["capabilities"]["cpu"]
|
||||
assert ops["capabilities"]["machines"]
|
||||
assert ops["hosts"]["data"]
|
||||
assert ops["journal"]
|
||||
assert ops["syslog"]
|
||||
assert ops["netstat"]
|
||||
assert ops["rrddata"]
|
||||
assert ops["status"]["uptime"]
|
||||
assert ops["ip"]
|
||||
assert meta["cluster_config"]["totem"]
|
||||
assert meta["quorate"] == 1
|
||||
guest = cast(
|
||||
dict[str, Any],
|
||||
enrich_guest_state({"name": "demo", "status": "stopped"}, kind="qemu", vmid="100"),
|
||||
)
|
||||
assert guest["agent"]["results"]["info"]
|
||||
assert guest["rrddata"]
|
||||
assert guest["migrate_preconditions"]
|
||||
storage = cast(
|
||||
dict[str, Any],
|
||||
enrich_storage_state({"total_bytes": 1000, "used_bytes": 100}, storage_id="local"),
|
||||
)
|
||||
assert storage["rrddata"]
|
||||
assert storage["file_restore"]
|
||||
assert storage["import_metadata"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_simulation_state_wipes_api_created_identity() -> None:
|
||||
executed: list[str] = []
|
||||
|
||||
Reference in New Issue
Block a user