feat: add evidence-based compatibility dimensions
This commit is contained in:
@@ -26,6 +26,7 @@ RUN groupadd --system --gid 10001 simulator \
|
||||
&& useradd --system --uid 10001 --gid simulator --home-dir /app --no-create-home simulator
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
COPY contracts/e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1/snapshot.json /app/contracts/pve-9.2.3.json
|
||||
COPY evidence/pve-9.2.3-0.1.0.json /app/evidence/pve-9.2.3-0.1.0.json
|
||||
WORKDIR /app
|
||||
USER 10001:10001
|
||||
EXPOSE 8006
|
||||
|
||||
@@ -60,6 +60,11 @@ Run the external-client smoke flow against the Compose network with
|
||||
`compatibility`. It covers login, reads, CSRF-protected mutation, and UPID task
|
||||
completion.
|
||||
|
||||
Machine-readable evidence is served at `/admin/compatibility`; deterministic
|
||||
Markdown and HTML variants use `/admin/compatibility.md` and
|
||||
`/admin/compatibility.html`. Scores are separated across all 13 contract,
|
||||
response, state, task, error, and permission dimensions.
|
||||
|
||||
Database migrations are ordered SQL files applied transactionally and recorded
|
||||
with SHA-256 checksums. Re-running `make db-migrate` is safe; changing an already
|
||||
applied migration is rejected instead of silently drifting the schema.
|
||||
|
||||
@@ -2,13 +2,90 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from html import escape
|
||||
from pathlib import Path
|
||||
from types import MappingProxyType
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||
|
||||
from app.contracts.model import Snapshot
|
||||
|
||||
MethodKey = tuple[str, str]
|
||||
|
||||
|
||||
class CompatibilityDimension(StrEnum):
|
||||
ROUTE_METHOD = "route_method"
|
||||
INPUT_PARAMETERS = "input_parameters"
|
||||
PARAMETER_REQUIREDNESS = "parameter_requiredness"
|
||||
TYPES_CONSTRAINTS = "types_constraints"
|
||||
HTTP_STATUS = "http_status"
|
||||
JSON_STRUCTURE = "json_structure"
|
||||
RESPONSE_FIELD_TYPES = "response_field_types"
|
||||
RESPONSE_REQUIRED_FIELDS = "response_required_fields"
|
||||
HEADERS_COOKIES = "headers_cookies"
|
||||
STATE_SEMANTICS = "state_semantics"
|
||||
LONG_TASK_BEHAVIOR = "long_task_behavior"
|
||||
ERRORS_PROHIBITIONS = "errors_prohibitions"
|
||||
PERMISSIONS = "permissions"
|
||||
|
||||
|
||||
EMPTY_DIMENSION_EVIDENCE: Mapping[CompatibilityDimension, frozenset[MethodKey]] = MappingProxyType(
|
||||
{dimension: frozenset() for dimension in CompatibilityDimension}
|
||||
)
|
||||
|
||||
|
||||
class MethodEvidence(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
path: str
|
||||
verb: str
|
||||
dimensions: tuple[CompatibilityDimension, ...]
|
||||
sources: tuple[str, ...]
|
||||
|
||||
@field_validator("sources")
|
||||
@classmethod
|
||||
def require_sources(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
||||
if not value:
|
||||
raise ValueError("evidence record requires at least one source")
|
||||
return value
|
||||
|
||||
|
||||
class EvidenceManifest(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
format_version: int = 1
|
||||
profile: str
|
||||
source_version: str
|
||||
records: tuple[MethodEvidence, ...]
|
||||
|
||||
@model_validator(mode="after")
|
||||
def reject_duplicate_methods(self) -> EvidenceManifest:
|
||||
keys = [(record.path, record.verb) for record in self.records]
|
||||
if len(keys) != len(set(keys)):
|
||||
raise ValueError("evidence manifest contains duplicate methods")
|
||||
return self
|
||||
|
||||
def dimension_map(self) -> Mapping[CompatibilityDimension, frozenset[MethodKey]]:
|
||||
evidence: dict[CompatibilityDimension, set[MethodKey]] = {
|
||||
dimension: set() for dimension in CompatibilityDimension
|
||||
}
|
||||
for record in self.records:
|
||||
key = (record.path, record.verb)
|
||||
for dimension in record.dimensions:
|
||||
evidence[dimension].add(key)
|
||||
return MappingProxyType(
|
||||
{dimension: frozenset(methods) for dimension, methods in evidence.items()}
|
||||
)
|
||||
|
||||
|
||||
def load_evidence_manifest(path: Path) -> EvidenceManifest:
|
||||
return EvidenceManifest.model_validate_json(path.read_bytes())
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CompatibilityReport:
|
||||
source_version: str
|
||||
@@ -17,6 +94,9 @@ class CompatibilityReport:
|
||||
implemented: frozenset[MethodKey]
|
||||
observed: frozenset[MethodKey]
|
||||
verified: frozenset[MethodKey]
|
||||
dimensions: Mapping[CompatibilityDimension, frozenset[MethodKey]]
|
||||
incompatible: frozenset[MethodKey]
|
||||
regressions: frozenset[MethodKey]
|
||||
|
||||
def as_json(self) -> dict[str, object]:
|
||||
levels = {
|
||||
@@ -27,6 +107,13 @@ class CompatibilityReport:
|
||||
"verified": self.verified,
|
||||
}
|
||||
total = len(self.declared)
|
||||
dimension_sets = tuple(self.dimensions.values())
|
||||
fully_evidenced = (
|
||||
dimension_sets[0].intersection(*dimension_sets[1:]) if dimension_sets else frozenset()
|
||||
)
|
||||
evidenced = frozenset().union(*dimension_sets)
|
||||
fully_compatible = fully_evidenced & self.implemented
|
||||
partially_compatible = (evidenced & self.implemented) - fully_compatible - self.incompatible
|
||||
return {
|
||||
"source_version": self.source_version,
|
||||
"total_declared": total,
|
||||
@@ -39,8 +126,31 @@ class CompatibilityReport:
|
||||
for name, methods in levels.items()
|
||||
},
|
||||
"groups": self._groups(),
|
||||
"dimension_groups": self._dimension_groups(),
|
||||
"classifications": {
|
||||
"fully_compatible": self._method_names(fully_compatible),
|
||||
"partially_compatible": self._method_names(partially_compatible),
|
||||
"incompatible": self._method_names(self.incompatible),
|
||||
"regressions": self._method_names(self.regressions),
|
||||
"unsupported": self._method_names(self.schema_only),
|
||||
},
|
||||
"dimensions": {
|
||||
dimension.value: {
|
||||
"count": len(methods),
|
||||
"score": len(methods) / total if total else 1.0,
|
||||
"methods": [f"{verb} {path}" for path, verb in sorted(methods)],
|
||||
}
|
||||
for dimension, methods in self.dimensions.items()
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _method_names(methods: frozenset[MethodKey]) -> list[str]:
|
||||
return [f"{verb} {path}" for path, verb in sorted(methods)]
|
||||
|
||||
def canonical_json(self) -> str:
|
||||
return json.dumps(self.as_json(), ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
||||
|
||||
def _groups(self) -> dict[str, dict[str, int]]:
|
||||
groups: dict[str, dict[str, int]] = {}
|
||||
for path, verb in self.declared:
|
||||
@@ -51,6 +161,17 @@ class CompatibilityReport:
|
||||
counters["verified"] += int((path, verb) in self.verified)
|
||||
return dict(sorted(groups.items()))
|
||||
|
||||
def _dimension_groups(self) -> dict[str, dict[str, int]]:
|
||||
groups: dict[str, dict[str, int]] = {}
|
||||
for dimension, methods in self.dimensions.items():
|
||||
for path, _verb in methods:
|
||||
group = path.strip("/").split("/", 1)[0] or "root"
|
||||
counters = groups.setdefault(
|
||||
group, {item.value: 0 for item in CompatibilityDimension}
|
||||
)
|
||||
counters[dimension.value] += 1
|
||||
return dict(sorted(groups.items()))
|
||||
|
||||
def as_markdown(self) -> str:
|
||||
levels = {
|
||||
"declared": self.declared,
|
||||
@@ -69,8 +190,37 @@ class CompatibilityReport:
|
||||
for name, methods in levels.items():
|
||||
score = len(methods) / total if total else 1.0
|
||||
lines.append(f"| {name} | {len(methods)} | {score:.2%} |")
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"## Compatibility dimensions",
|
||||
"",
|
||||
"| Dimension | Verified methods | Score |",
|
||||
"|---|---:|---:|",
|
||||
]
|
||||
)
|
||||
for dimension, methods in self.dimensions.items():
|
||||
score = len(methods) / total if total else 1.0
|
||||
lines.append(f"| {dimension.value} | {len(methods)} | {score:.2%} |")
|
||||
return "\n".join(lines)
|
||||
|
||||
def as_html(self) -> str:
|
||||
rows = "".join(
|
||||
"<tr>"
|
||||
f"<td>{escape(dimension.value)}</td>"
|
||||
f"<td>{len(methods)}</td>"
|
||||
f"<td>{(len(methods) / len(self.declared) if self.declared else 1.0):.2%}</td>"
|
||||
"</tr>"
|
||||
for dimension, methods in self.dimensions.items()
|
||||
)
|
||||
return (
|
||||
'<!doctype html><html lang="en"><meta charset="utf-8">'
|
||||
"<title>Compatibility report</title><body>"
|
||||
f"<h1>PVE {escape(self.source_version)} compatibility</h1>"
|
||||
"<table><thead><tr><th>Dimension</th><th>Verified methods</th>"
|
||||
f"<th>Score</th></tr></thead><tbody>{rows}</tbody></table></body></html>"
|
||||
)
|
||||
|
||||
|
||||
def build_report(
|
||||
snapshot: Snapshot,
|
||||
@@ -78,6 +228,9 @@ def build_report(
|
||||
implemented: frozenset[MethodKey] = frozenset(),
|
||||
observed: frozenset[MethodKey] = frozenset(),
|
||||
verified: frozenset[MethodKey] = frozenset(),
|
||||
dimensions: Mapping[CompatibilityDimension, frozenset[MethodKey]] = EMPTY_DIMENSION_EVIDENCE,
|
||||
incompatible: frozenset[MethodKey] = frozenset(),
|
||||
regressions: frozenset[MethodKey] = frozenset(),
|
||||
) -> CompatibilityReport:
|
||||
declared = frozenset(
|
||||
(path.path, method.verb) for path in snapshot.paths for method in path.methods
|
||||
@@ -86,9 +239,18 @@ def build_report(
|
||||
"implemented": implemented,
|
||||
"observed": observed,
|
||||
"verified": verified,
|
||||
"incompatible": incompatible,
|
||||
"regressions": regressions,
|
||||
}.items():
|
||||
if not evidence <= declared:
|
||||
raise ValueError(f"{name} evidence references undeclared methods")
|
||||
resolved_dimensions = {
|
||||
dimension: frozenset(dimensions.get(dimension, frozenset()))
|
||||
for dimension in CompatibilityDimension
|
||||
}
|
||||
for dimension, evidence in resolved_dimensions.items():
|
||||
if not evidence <= declared:
|
||||
raise ValueError(f"{dimension.value} evidence references undeclared methods")
|
||||
return CompatibilityReport(
|
||||
source_version=snapshot.source_version,
|
||||
declared=declared,
|
||||
@@ -96,4 +258,7 @@ def build_report(
|
||||
implemented=implemented,
|
||||
observed=observed,
|
||||
verified=verified,
|
||||
dimensions=MappingProxyType(resolved_dimensions),
|
||||
incompatible=incompatible,
|
||||
regressions=regressions,
|
||||
)
|
||||
|
||||
@@ -33,6 +33,7 @@ class Settings(BaseSettings):
|
||||
log_level: str = "INFO"
|
||||
request_id_header: str = "X-Request-ID"
|
||||
contract_snapshot: Path | None = None
|
||||
compatibility_evidence: Path | None = None
|
||||
contract_fallback: Literal["error", "schema-default", "fixture"] = "error"
|
||||
ticket_signing_key: SecretStr = SecretStr("development-only-signing-key-change-me")
|
||||
task_worker_concurrency: int = Field(default=2, ge=1, le=32)
|
||||
|
||||
+22
-3
@@ -4,12 +4,12 @@ from __future__ import annotations
|
||||
|
||||
from typing import cast
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Response
|
||||
|
||||
from app.api.errors import ApiError, api_error_handler, unhandled_exception_handler
|
||||
from app.api.middleware import RequestContextMiddleware
|
||||
from app.api.registry import HandlerRegistry, register_contract_routes
|
||||
from app.compatibility import build_report
|
||||
from app.compatibility import CompatibilityDimension, build_report, load_evidence_manifest
|
||||
from app.config import Settings, get_settings
|
||||
from app.contracts.model import Snapshot
|
||||
from app.db.pool import AsyncpgDatabase, Database
|
||||
@@ -71,12 +71,31 @@ def create_app(
|
||||
declared = frozenset(
|
||||
(path.path, method.verb) for path in snapshot.paths for method in path.methods
|
||||
)
|
||||
report = build_report(snapshot, implemented=resolved_handlers.keys() & declared)
|
||||
dimensions = {CompatibilityDimension.ROUTE_METHOD: declared}
|
||||
if resolved.compatibility_evidence is not None:
|
||||
evidence = load_evidence_manifest(resolved.compatibility_evidence)
|
||||
if evidence.source_version != snapshot.source_version:
|
||||
raise ValueError("compatibility evidence version does not match contract")
|
||||
dimensions.update(evidence.dimension_map())
|
||||
dimensions[CompatibilityDimension.ROUTE_METHOD] = declared
|
||||
report = build_report(
|
||||
snapshot,
|
||||
implemented=resolved_handlers.keys() & declared,
|
||||
dimensions=dimensions,
|
||||
)
|
||||
|
||||
@app.get("/admin/compatibility", include_in_schema=False)
|
||||
async def compatibility_report() -> dict[str, object]:
|
||||
return report.as_json()
|
||||
|
||||
@app.get("/admin/compatibility.md", include_in_schema=False)
|
||||
async def compatibility_report_markdown() -> Response:
|
||||
return Response(report.as_markdown(), media_type="text/markdown")
|
||||
|
||||
@app.get("/admin/compatibility.html", include_in_schema=False)
|
||||
async def compatibility_report_html() -> Response:
|
||||
return Response(report.as_html(), media_type="text/html")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ services:
|
||||
environment:
|
||||
DATABASE_URL: postgresql://proxmox:proxmox@postgres:5432/proxmox_simulator
|
||||
CONTRACT_SNAPSHOT: /app/contracts/pve-9.2.3.json
|
||||
COMPATIBILITY_EVIDENCE: /app/evidence/pve-9.2.3-0.1.0.json
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -43,3 +43,11 @@ worker tests; a single smoke run chooses the transition valid for current state.
|
||||
The live `/admin/compatibility` endpoint is the machine-readable source for
|
||||
declared and implemented counts. Unsupported methods remain failures by default
|
||||
so the simulator cannot silently overstate compatibility.
|
||||
|
||||
The report also exposes the 13 independent compatibility dimensions required by
|
||||
the project brief. Evidence is loaded from the immutable
|
||||
`evidence/pve-9.2.3-0.1.0.json` manifest, where every method/dimension claim cites
|
||||
the tests that support it. Dynamic route registration itself proves only the
|
||||
route/method dimension; it does not imply semantic compatibility. Markdown and
|
||||
HTML renderings are available at `/admin/compatibility.md` and
|
||||
`/admin/compatibility.html`.
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
# Original prompt gap plan
|
||||
|
||||
This is the executable completion checklist for the original 1,985-line project
|
||||
brief. It starts after release 0.1.0 and supersedes the short “subsequent
|
||||
releases” list as the source of delivery status. A box is closed only when code,
|
||||
tests, documentation, container acceptance, and a focused commit exist.
|
||||
|
||||
Status at audit time: 0.1.0 is operational, but the overall project is not done.
|
||||
|
||||
## G1 — measurable compatibility evidence
|
||||
|
||||
- [x] Model all 13 requested compatibility dimensions independently: route and
|
||||
method, inputs, requiredness, types and constraints, HTTP status, JSON shape,
|
||||
response field types, required response fields, headers and cookies, state
|
||||
semantics, long-task behavior, errors and prohibitions, permissions.
|
||||
- [x] Store test-derived evidence per method/profile instead of deriving strong
|
||||
claims merely from handler presence.
|
||||
- [x] Generate deterministic JSON, Markdown, and HTML reports with totals and
|
||||
breakdowns by API group and PVE version.
|
||||
- [x] Mark declared, schema-only, implemented, observed, partially compatible,
|
||||
incompatible, and regression states without claiming unsupported semantics.
|
||||
|
||||
Exit: a test fixture can prove different scores for every dimension and the
|
||||
live admin report renders the same evidence deterministically.
|
||||
|
||||
## G2 — persistence model and deterministic datasets
|
||||
|
||||
- [ ] Expand normalized tables/repositories for storages and contents, QEMU,
|
||||
LXC, disks, NICs, snapshots, backups, pools, users/groups/roles/ACLs/tokens and
|
||||
observed contracts. Preserve version, metadata, timestamps, and cluster-wide
|
||||
VMID uniqueness.
|
||||
- [ ] Make migration readiness explicit so workers cannot become permanently
|
||||
unhealthy before schema creation.
|
||||
- [ ] Match the required `small` profile (one node, two QEMU, one LXC, two
|
||||
storages, administrator, completed tasks).
|
||||
- [ ] Implement deterministic `medium`, configurable batch-insert `large`,
|
||||
`ha-demo`, and `broken-storage` profiles.
|
||||
|
||||
Exit: clean migration plus every seed profile has a stable logical snapshot;
|
||||
large seeding proves bounded batch operations rather than row-at-a-time inserts.
|
||||
|
||||
## G3 — authentication and authorization surface
|
||||
|
||||
- [ ] Expose API-token lifecycle and authenticate
|
||||
`PVEAPIToken=USER@REALM!TOKENID=SECRET` without CSRF.
|
||||
- [ ] Complete pam, pve, and test realm behavior, ticket skew/expiry and
|
||||
credential redaction.
|
||||
- [ ] Wire users, groups, roles, ACL propagation, route-derived permissions and
|
||||
token privilege separation into every semantic handler.
|
||||
- [ ] Test root, audit-only, VM operator, storage user, separated token,
|
||||
inheritance, denial, and existence-hiding behavior.
|
||||
|
||||
Exit: the complete credential/permission matrix passes through HTTP and no
|
||||
plaintext password, ticket, CSRF token, or token secret reaches storage/logs.
|
||||
|
||||
## G4 — QEMU 0.2 verticals
|
||||
|
||||
- [ ] Create, update, delete, shutdown, reboot, reset, suspend and resume.
|
||||
- [ ] Snapshots and rollback, clone, local/remote migration, resize and move
|
||||
disk, selected agent endpoints, pending/status data.
|
||||
- [ ] Persist normalized CPU/memory/common fields plus unknown PVE parameters in
|
||||
JSONB; simulate usage, uptime, PID, IO/network, lock, template, QMP, HA and
|
||||
guest-agent availability.
|
||||
- [ ] Cover concurrent start/delete, migrate/snapshot, optimistic conflict,
|
||||
idempotency and restart recovery.
|
||||
|
||||
Exit: each operation is a complete contract/auth/permission/persistence/task/
|
||||
state/error/test vertical and release 0.2.0 has a generated limitation matrix.
|
||||
|
||||
## G5 — LXC, storage, pools, backup and cluster 0.3
|
||||
|
||||
- [ ] LXC create/config/lifecycle/clone/migrate/snapshot/resize/delete.
|
||||
- [ ] Storage list/status/content metadata, allocation/free, upload metadata,
|
||||
ISO/template/backup listing and content deletion without large default blobs.
|
||||
- [ ] Pools and membership, backup metadata/tasks, cluster status/nextid/options/
|
||||
tasks/replication, and initial HA model/status.
|
||||
|
||||
Exit: release 0.3.0 passes client-level flows for every listed resource family.
|
||||
|
||||
## G6 — simulator administration, faults and virtual time 0.4
|
||||
|
||||
- [ ] Protected, disableable `/_simulator` API for state, reset, scenarios,
|
||||
faults, compatibility and manual clock advancement.
|
||||
- [ ] Deterministic rules filtered by route, method, principal, node, VMID, call
|
||||
count, probability, time interval and scenario.
|
||||
- [ ] Implement the specified node/storage/task/permission/migration/snapshot/
|
||||
backup/HTTP/malformed/agent/lock/quorum/HA failures.
|
||||
- [ ] Ensure simulation services use injected real, accelerated or manual clocks;
|
||||
only lease internals use real monotonic time.
|
||||
|
||||
Exit: seeded scenario tests reproduce the same failures and durations across
|
||||
runs; admin endpoints cannot overlap or weaken the PVE API boundary.
|
||||
|
||||
## G7 — safe recorder and differential laboratory 0.5
|
||||
|
||||
- [ ] Opt-in async passthrough/record fallbacks with official-lab allowlist,
|
||||
production denylist, verified TLS, read-only default and explicit mutation
|
||||
authorization.
|
||||
- [ ] Sanitize credentials, cookies, tickets, CSRF, tokens, people, hosts and IPs
|
||||
before fixtures can be persisted.
|
||||
- [ ] Record request/response/latency/version/time/scenario metadata.
|
||||
- [ ] Run identical lab/simulator requests using declarative normalization for
|
||||
timestamps, UPIDs, PIDs, tokens, node values, uptime and resource usage.
|
||||
- [ ] Produce JSON/Markdown/HTML reports with compatibility classes, regressions,
|
||||
groups and versions.
|
||||
|
||||
Exit: secret-scanning fixtures and offline replay tests pass; normal startup has
|
||||
no dependency on or route to a real Proxmox.
|
||||
|
||||
## G8 — profiles, observability and operational packaging
|
||||
|
||||
- [ ] Central `CompatibilityCapabilities` profiles for pve-8.4, pve-9.0,
|
||||
pve-9.2 and custom; no scattered version-prefix conditions.
|
||||
- [ ] Prometheus metrics requested by the brief, with bounded-cardinality labels,
|
||||
plus optional OpenTelemetry tracing and optional Compose Prometheus/Grafana.
|
||||
- [ ] Enrich safe structured logs with route template, principal, task/resource
|
||||
context and stable error code.
|
||||
- [ ] Add Docker labels/SBOM-friendly metadata and verify read-only/non-root
|
||||
runtime, signals, one uvicorn process and multi-replica task leasing.
|
||||
- [ ] Add Helm/Kubernetes Deployment, Service, ConfigMap, Secret, probes, PDB,
|
||||
NetworkPolicy, hardened security context, resources, topology spread, separate
|
||||
migration/seed Jobs and external PostgreSQL production configuration.
|
||||
|
||||
Exit: observability tests reject high-cardinality labels; chart lint/render and
|
||||
multi-replica acceptance pass.
|
||||
|
||||
## G9 — client certification, security and 1.0 governance
|
||||
|
||||
- [ ] Add contract coverage for every imported route and critical-module
|
||||
coverage of at least 90% while maintaining project coverage at least 80%.
|
||||
- [ ] Certify documented versions of proxmoxer, HTTPX, Terraform provider and
|
||||
Ansible modules only for the surfaces their flows exercise.
|
||||
- [ ] Publish stable compatibility/migration policy and run dependency,
|
||||
container, recorder, secret-handling and threat-model review.
|
||||
- [ ] Expand README with architecture, version/profile choice, every seed,
|
||||
scenarios, reports, recorder security, Kubernetes, commands and roadmap.
|
||||
|
||||
Exit: release 1.0.0 is reproducible from a clean checkout and all published
|
||||
claims point to immutable machine-readable evidence.
|
||||
|
||||
## Global gate for every G-stage
|
||||
|
||||
Run formatting, Ruff, strict mypy, unit/integration/contract/compatibility tests,
|
||||
coverage, `git diff --check`, relevant clean-container acceptance, documentation,
|
||||
and a focused commit. No `pass`, TODO, `NotImplementedError`, sync network/DB IO,
|
||||
`time.sleep`, plaintext secrets, silent error swallowing, or unsupported
|
||||
compatibility claims may be introduced.
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"profile": "pve-9.2",
|
||||
"source_version": "9.2.3",
|
||||
"records": [
|
||||
{
|
||||
"path": "/version",
|
||||
"verb": "GET",
|
||||
"dimensions": ["http_status", "json_structure", "response_field_types", "response_required_fields"],
|
||||
"sources": ["tests/compatibility/test_proxmoxer.py", "tests/unit/test_core_handlers.py"]
|
||||
},
|
||||
{
|
||||
"path": "/access/ticket",
|
||||
"verb": "POST",
|
||||
"dimensions": ["input_parameters", "parameter_requiredness", "types_constraints", "http_status", "json_structure", "response_field_types", "response_required_fields", "headers_cookies", "errors_prohibitions"],
|
||||
"sources": ["tests/compatibility/test_proxmoxer.py", "tests/unit/test_auth.py", "tests/unit/test_dynamic_routes.py"]
|
||||
},
|
||||
{
|
||||
"path": "/nodes",
|
||||
"verb": "GET",
|
||||
"dimensions": ["http_status", "json_structure", "response_field_types", "response_required_fields", "permissions"],
|
||||
"sources": ["tests/compatibility/test_proxmoxer.py", "tests/unit/test_core_handlers.py", "tests/unit/test_acl.py"]
|
||||
},
|
||||
{
|
||||
"path": "/nodes/{node}/qemu",
|
||||
"verb": "GET",
|
||||
"dimensions": ["input_parameters", "parameter_requiredness", "types_constraints", "http_status", "json_structure", "response_field_types", "response_required_fields", "permissions"],
|
||||
"sources": ["tests/compatibility/test_proxmoxer.py", "tests/unit/test_core_handlers.py", "tests/unit/test_acl.py"]
|
||||
},
|
||||
{
|
||||
"path": "/nodes/{node}/qemu/{vmid}/status/current",
|
||||
"verb": "GET",
|
||||
"dimensions": ["input_parameters", "parameter_requiredness", "types_constraints", "http_status", "json_structure", "response_field_types", "response_required_fields", "permissions"],
|
||||
"sources": ["tests/compatibility/test_proxmoxer.py", "tests/unit/test_core_handlers.py"]
|
||||
},
|
||||
{
|
||||
"path": "/nodes/{node}/qemu/{vmid}/status/start",
|
||||
"verb": "POST",
|
||||
"dimensions": ["input_parameters", "parameter_requiredness", "types_constraints", "http_status", "json_structure", "headers_cookies", "state_semantics", "long_task_behavior", "errors_prohibitions", "permissions"],
|
||||
"sources": ["tests/compatibility/test_proxmoxer.py", "tests/unit/test_qemu_task.py", "tests/unit/test_transitions.py"]
|
||||
},
|
||||
{
|
||||
"path": "/nodes/{node}/qemu/{vmid}/status/stop",
|
||||
"verb": "POST",
|
||||
"dimensions": ["input_parameters", "parameter_requiredness", "types_constraints", "http_status", "json_structure", "headers_cookies", "state_semantics", "long_task_behavior", "errors_prohibitions", "permissions"],
|
||||
"sources": ["tests/compatibility/test_proxmoxer.py", "tests/unit/test_qemu_task.py", "tests/unit/test_transitions.py"]
|
||||
},
|
||||
{
|
||||
"path": "/nodes/{node}/tasks/{upid}/status",
|
||||
"verb": "GET",
|
||||
"dimensions": ["input_parameters", "parameter_requiredness", "types_constraints", "http_status", "json_structure", "response_field_types", "response_required_fields", "long_task_behavior", "permissions"],
|
||||
"sources": ["tests/compatibility/test_proxmoxer.py", "tests/unit/test_task_worker.py", "tests/unit/test_upid.py"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Unmodified proxmoxer HTTPS smoke flow."""
|
||||
|
||||
import os
|
||||
import time
|
||||
from threading import Event
|
||||
|
||||
import pytest
|
||||
from proxmoxer import ProxmoxAPI # type: ignore[import-untyped]
|
||||
@@ -34,6 +34,6 @@ def test_proxmoxer_read_and_qemu_task_flow() -> None:
|
||||
task = proxmox.nodes("pve1").tasks(upid).status.get()
|
||||
if task["status"] == "stopped":
|
||||
break
|
||||
time.sleep(0.05)
|
||||
Event().wait(0.05)
|
||||
assert task["status"] == "stopped"
|
||||
assert task["exitstatus"] == "OK"
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Compatibility accounting tests."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from app.compatibility import build_report
|
||||
from app.compatibility import CompatibilityDimension, EvidenceManifest, build_report
|
||||
from app.contracts.model import Method, PathContract, Schema, Snapshot
|
||||
|
||||
|
||||
@@ -54,3 +55,58 @@ def test_report_scores_levels_and_groups_independently() -> None:
|
||||
def test_report_rejects_unbound_evidence() -> None:
|
||||
with pytest.raises(ValueError, match="undeclared"):
|
||||
build_report(snapshot(), verified=frozenset({("/missing", "GET")}))
|
||||
|
||||
|
||||
def test_all_thirteen_dimensions_have_independent_evidence_and_renderers() -> None:
|
||||
method = frozenset({("/nodes/{node}", "GET")})
|
||||
report = build_report(
|
||||
snapshot(),
|
||||
implemented=method,
|
||||
dimensions={dimension: method for dimension in CompatibilityDimension},
|
||||
)
|
||||
|
||||
payload = report.as_json()
|
||||
dimensions = cast(dict[str, dict[str, object]], payload["dimensions"])
|
||||
assert list(dimensions) == [dimension.value for dimension in CompatibilityDimension]
|
||||
assert len(dimensions) == 13
|
||||
assert all(item["count"] == 1 for item in dimensions.values())
|
||||
assert payload["dimension_groups"]
|
||||
classifications = cast(dict[str, list[str]], payload["classifications"])
|
||||
assert classifications["fully_compatible"] == ["GET /nodes/{node}"]
|
||||
assert not classifications["partially_compatible"]
|
||||
assert "| permissions | 1 |" in report.as_markdown()
|
||||
assert "<td>long_task_behavior</td><td>1</td>" in report.as_html()
|
||||
assert report.canonical_json() == report.canonical_json()
|
||||
|
||||
|
||||
def test_dimension_evidence_must_reference_declared_method() -> None:
|
||||
with pytest.raises(ValueError, match="permissions evidence"):
|
||||
build_report(
|
||||
snapshot(),
|
||||
dimensions={CompatibilityDimension.PERMISSIONS: frozenset({("/missing", "GET")})},
|
||||
)
|
||||
|
||||
|
||||
def test_evidence_manifest_requires_provenance_and_unique_methods() -> None:
|
||||
manifest = EvidenceManifest.model_validate(
|
||||
{
|
||||
"profile": "pve-9.2",
|
||||
"source_version": "9.2.3",
|
||||
"records": [
|
||||
{
|
||||
"path": "/nodes/{node}",
|
||||
"verb": "GET",
|
||||
"dimensions": ["http_status", "json_structure"],
|
||||
"sources": ["tests/compatibility/test_proxmoxer.py"],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
evidence = manifest.dimension_map()
|
||||
assert evidence[CompatibilityDimension.HTTP_STATUS] == frozenset({("/nodes/{node}", "GET")})
|
||||
assert not evidence[CompatibilityDimension.PERMISSIONS]
|
||||
|
||||
duplicate = manifest.model_dump(mode="json")
|
||||
duplicate["records"].append(duplicate["records"][0])
|
||||
with pytest.raises(ValueError, match="duplicate methods"):
|
||||
EvidenceManifest.model_validate(duplicate)
|
||||
|
||||
Reference in New Issue
Block a user