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:
Sergey Antropoff
2026-07-18 04:18:05 +03:00
parent 777926487b
commit 48df10b17e
172 changed files with 7528 additions and 1208 deletions
+14 -14
View File
@@ -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()
+9 -2
View File
@@ -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:")
+24 -2
View File
@@ -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
+6 -2
View File
@@ -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
+13 -1
View File
@@ -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:
+70
View File
@@ -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] = []