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:
@@ -0,0 +1,14 @@
|
||||
API_URL=http://simulator:8006
|
||||
API_USER=root@pam
|
||||
API_PASSWORD=secret
|
||||
API_TOKEN=root@pam!automation=automation-secret
|
||||
API_AUDITOR_USER=auditor@pve
|
||||
API_AUDITOR_PASSWORD=auditor-secret
|
||||
API_AUDITOR_TOKEN=auditor@pve!readonly=readonly-secret
|
||||
PVE_NODE=pve01
|
||||
PVE_STORAGE=local-lvm
|
||||
PVE_BRIDGE=vmbr0
|
||||
API_TIMEOUT=120
|
||||
API_POLL_INTERVAL=0.2
|
||||
TEST_RESOURCE_PREFIX=hx
|
||||
SEED_PROFILE=small
|
||||
@@ -0,0 +1,11 @@
|
||||
.env
|
||||
**/reports/*.xml
|
||||
**/reports/*.html
|
||||
**/reports/*.json
|
||||
pulumi/.pulumi-state/
|
||||
pulumi/.pulumi-work/
|
||||
pulumi/programs/**/.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
*.retry
|
||||
@@ -0,0 +1,43 @@
|
||||
COMPOSE := docker compose -f docker/docker-compose.yml
|
||||
ENV_FILE := .env
|
||||
|
||||
.PHONY: help env up down build seed test test-smoke clean-test-resources reports-dir
|
||||
|
||||
help: ## Show targets
|
||||
@awk 'BEGIN {FS = ":.*## "}; /^[a-zA-Z0-9_-]+:.*## / {printf "%-24s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||
|
||||
env: ## Copy .env.example → .env if missing
|
||||
@test -f $(ENV_FILE) || cp .env.example $(ENV_FILE)
|
||||
|
||||
up: env ## Start postgres + simulator + internal TLS (for pulumi-proxmoxve) + seed
|
||||
$(COMPOSE) --env-file $(ENV_FILE) up -d --build postgres migrate simulator tls-gateway
|
||||
$(COMPOSE) --env-file $(ENV_FILE) run --rm seed
|
||||
|
||||
down: ## Stop test stack
|
||||
$(COMPOSE) --env-file $(ENV_FILE) down -v
|
||||
|
||||
build: env ## Build simulator + pulumi runner
|
||||
$(COMPOSE) --env-file $(ENV_FILE) --profile pulumi build
|
||||
|
||||
seed: env ## Reseed simulator (small profile by default)
|
||||
$(COMPOSE) --env-file $(ENV_FILE) run --rm seed
|
||||
|
||||
reports-dir:
|
||||
@mkdir -p pulumi/reports reports
|
||||
|
||||
test: env reports-dir ## Full suite: surface majors 6–9 + lifecycle (HTML report)
|
||||
$(COMPOSE) --env-file $(ENV_FILE) --profile pulumi run --rm --entrypoint /usr/local/bin/python3 pulumi-runner \
|
||||
pulumi/run_suite.py \
|
||||
--report-html pulumi/reports/report.html \
|
||||
--report-json pulumi/reports/results.json \
|
||||
--report-junit pulumi/reports/junit.xml
|
||||
|
||||
test-smoke: env reports-dir ## Smoke: surface major 9 + short lifecycle
|
||||
$(COMPOSE) --env-file $(ENV_FILE) --profile pulumi run --rm --entrypoint /usr/local/bin/python3 pulumi-runner \
|
||||
pulumi/run_suite.py --smoke \
|
||||
--report-html pulumi/reports/report-smoke.html \
|
||||
--report-json pulumi/reports/results-smoke.json \
|
||||
--report-junit pulumi/reports/junit-smoke.xml
|
||||
|
||||
clean-test-resources: env ## Delete leftover hx* guests on simulator
|
||||
$(COMPOSE) --env-file $(ENV_FILE) --profile cleanup run --rm cleanup
|
||||
@@ -0,0 +1,49 @@
|
||||
**Language / Язык:** [English](README.md) | [Русский](README.ru.md)
|
||||
|
||||
# Pulumi integration suite
|
||||
|
||||
Docker-only suite against the Proxmox API simulator.
|
||||
|
||||
| Layer | What it covers |
|
||||
|---|---|
|
||||
| **Surface** | **100%** of declared contract methods for PVE majors **6–9** (every path+verb) via HTTP (`pvelib/surface.py`). Suite PASS requires `declared == probed` per major and zero critical failures (501, “not supported” messages, 5xx, exceptions, unknown verbs). |
|
||||
| **Lifecycle** | **`pulumi-proxmoxve` only** (BPG Terraform bridge) — Provider, inventory data sources, `VmLegacy` + non-empty checks. Negative auth via httpx. This is **not** full API coverage; the provider exposes dozens of resources, not thousands of contract methods. Provider uses internal HTTPS gateway; surface stays HTTP. |
|
||||
|
||||
Reports (`pulumi/reports/report.html`, `results.json`, `junit.xml`) include a **Full contract coverage** summary with per-major and total `declared`/`probed` counts (e.g. `Coverage: 2324/2324 methods across majors 6–9`).
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
pulumi-tests/
|
||||
pulumi/
|
||||
run_suite.py
|
||||
report.py
|
||||
pvelib/ # httpx client + surface probe
|
||||
programs/lifecycle/ # pulumi-proxmoxve program
|
||||
docker/docker-compose.yml
|
||||
Makefile
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
make pulumi-tests
|
||||
```
|
||||
|
||||
Or:
|
||||
|
||||
```bash
|
||||
cd pulumi-tests
|
||||
make up
|
||||
make test-smoke
|
||||
make test
|
||||
open pulumi/reports/report.html
|
||||
make down
|
||||
```
|
||||
|
||||
Provider env (set by Compose): `PROXMOX_VE_ENDPOINT=https://tls-gateway:8443/`
|
||||
(internal suite TLS — `pulumi-proxmoxve` rejects `http://`), plus username/password
|
||||
/`INSECURE`. Surface probe uses `API_URL=http://simulator:8006`. Host lab URL
|
||||
remains plain `http://localhost:8006/` (Kubernetes HTTPS is Ingress-only).
|
||||
@@ -0,0 +1,49 @@
|
||||
**Language / Язык:** [English](README.md) | [Русский](README.ru.md)
|
||||
|
||||
# Интеграционный набор Pulumi
|
||||
|
||||
Suite только в Docker против симулятора Proxmox API.
|
||||
|
||||
| Слой | Покрытие |
|
||||
|---|---|
|
||||
| **Surface** | **100%** объявленных методов контракта majors **6–9** (каждый path+verb) по HTTP (`pvelib/surface.py`). PASS только при `declared == probed` на каждый major и нуле критичных сбоев (501, «not supported», 5xx, exceptions, неизвестные verb). |
|
||||
| **Lifecycle** | Только **`pulumi-proxmoxve`** (мост BPG) — Provider, data sources инвентаря, `VmLegacy` + проверки непустых outputs. Это **не** полное покрытие API: в провайдере десятки ресурсов, не тысячи contract methods. HTTPS через lab TLS-шлюз; surface — HTTP. |
|
||||
|
||||
В отчётах (`pulumi/reports/report.html`, `results.json`, `junit.xml`) — блок **Full contract coverage** с итогами `declared`/`probed` по major и суммой (например `Coverage: 2324/2324 methods across majors 6–9`).
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
pulumi-tests/
|
||||
pulumi/
|
||||
run_suite.py
|
||||
report.py
|
||||
pvelib/ # httpx + surface probe
|
||||
programs/lifecycle/ # программа pulumi-proxmoxve
|
||||
docker/docker-compose.yml
|
||||
Makefile
|
||||
```
|
||||
|
||||
## Запуск
|
||||
|
||||
Из корня репозитория:
|
||||
|
||||
```bash
|
||||
make pulumi-tests
|
||||
```
|
||||
|
||||
Или:
|
||||
|
||||
```bash
|
||||
cd pulumi-tests
|
||||
make up
|
||||
make test-smoke
|
||||
make test
|
||||
open pulumi/reports/report.html
|
||||
make down
|
||||
```
|
||||
|
||||
Env провайдера (Compose): `PROXMOX_VE_ENDPOINT=https://tls-gateway:8443/`
|
||||
(внутренний TLS suite — `pulumi-proxmoxve` не принимает `http://`), плюс
|
||||
username/password/`INSECURE`. Surface probe: `API_URL=http://simulator:8006`.
|
||||
Хостовый lab URL — plain `http://localhost:8006/` (HTTPS в K8s — только Ingress).
|
||||
@@ -0,0 +1,22 @@
|
||||
FROM python:3.13-bookworm
|
||||
|
||||
ARG PULUMI_VERSION=3.193.0
|
||||
ARG PULUMI_PROXMOXVE_VERSION=8.2.1
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl ca-certificates \
|
||||
&& curl -fsSL https://get.pulumi.com | sh -s -- --version "${PULUMI_VERSION}" \
|
||||
&& ln -s /root/.pulumi/bin/pulumi /usr/local/bin/pulumi \
|
||||
&& pip install --no-cache-dir \
|
||||
"httpx==0.28.1" \
|
||||
"pulumi==${PULUMI_VERSION}" \
|
||||
"pulumi-proxmoxve==${PULUMI_PROXMOXVE_VERSION}" \
|
||||
&& pulumi plugin install resource proxmoxve "${PULUMI_PROXMOXVE_VERSION}" \
|
||||
--server github://api.github.com/muhlba91/pulumi-proxmoxve \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /workspace/pulumi-tests
|
||||
ENV PYTHONPATH=/workspace/pulumi-tests/pulumi:/workspace/pulumi-tests
|
||||
ENV PULUMI_CONFIG_PASSPHRASE=hx-test-passphrase
|
||||
|
||||
CMD ["python", "pulumi/run_suite.py"]
|
||||
@@ -0,0 +1,188 @@
|
||||
name: proxmox-hx-tests
|
||||
|
||||
x-test-env: &test-env
|
||||
API_URL: http://simulator:8006
|
||||
API_USER: ${API_USER:-root@pam}
|
||||
API_PASSWORD: ${API_PASSWORD:-secret}
|
||||
API_TOKEN: ${API_TOKEN:-root@pam!automation=automation-secret}
|
||||
PVE_NODE: ${PVE_NODE:-pve01}
|
||||
PVE_STORAGE: ${PVE_STORAGE:-local-lvm}
|
||||
PVE_BRIDGE: ${PVE_BRIDGE:-vmbr0}
|
||||
API_TIMEOUT: ${API_TIMEOUT:-120}
|
||||
TEST_RESOURCE_PREFIX: ${TEST_RESOURCE_PREFIX:-hx}
|
||||
PYTHONPATH: /workspace/pulumi-tests/pulumi:/workspace/pulumi-tests
|
||||
CONTRACTS_ROOT: /workspace/contracts
|
||||
SIMULATION_TIME_SCALE: "10"
|
||||
# pulumi-proxmoxve requires https://… endpoints; keep TLS internal to this suite.
|
||||
# Host-facing Compose lab stays plain HTTP on :8006 (K8s TLS = Ingress only).
|
||||
PROXMOX_VE_ENDPOINT: https://tls-gateway:8443/
|
||||
PROXMOX_VE_USERNAME: ${API_USER:-root@pam}
|
||||
PROXMOX_VE_PASSWORD: ${API_PASSWORD:-secret}
|
||||
PROXMOX_VE_INSECURE: "true"
|
||||
|
||||
x-simulator-env: &simulator-env
|
||||
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.json
|
||||
LOG_LEVEL: INFO
|
||||
TICKET_SIGNING_KEY: development-only-signing-key-change-me
|
||||
TASK_WORKER_CONCURRENCY: "4"
|
||||
SIMULATION_TIME_SCALE: "10"
|
||||
|
||||
networks:
|
||||
hx:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
pulumi-state:
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17.5-bookworm
|
||||
networks: [hx]
|
||||
environment:
|
||||
POSTGRES_DB: proxmox_simulator
|
||||
POSTGRES_USER: proxmox
|
||||
POSTGRES_PASSWORD: proxmox
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U proxmox -d proxmox_simulator"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
start_period: 5s
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
|
||||
migrate:
|
||||
build:
|
||||
context: ../..
|
||||
target: runtime
|
||||
image: proxmox-api-simulator:0.1.0
|
||||
networks: [hx]
|
||||
environment:
|
||||
<<: *simulator-env
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
entrypoint: ["python"]
|
||||
command: ["-m", "app.db.migrate_cli"]
|
||||
restart: "no"
|
||||
|
||||
simulator:
|
||||
build:
|
||||
context: ../..
|
||||
target: runtime
|
||||
image: proxmox-api-simulator:0.1.0
|
||||
networks: [hx]
|
||||
environment:
|
||||
<<: *simulator-env
|
||||
depends_on:
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
command: ["--host", "0.0.0.0", "--port", "8006"]
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"python",
|
||||
"-c",
|
||||
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8006/health/ready', timeout=2)",
|
||||
]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
start_period: 15s
|
||||
ports:
|
||||
- "${HX_SIMULATOR_HOST_PORT:-127.0.0.1:18006}:8006"
|
||||
|
||||
# Internal-only HTTPS for pulumi-proxmoxve (provider rejects http:// endpoints).
|
||||
# Not published on host :8006 — lab HTTPS for end users is K8s Ingress.
|
||||
tls-gateway:
|
||||
image: nginx:1.28.0-alpine
|
||||
networks: [hx]
|
||||
depends_on:
|
||||
simulator:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ../../docker/tls/gateway.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
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"wget -qO- --no-check-certificate https://127.0.0.1:8443/health/ready || exit 1",
|
||||
]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
start_period: 5s
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /var/cache/nginx
|
||||
- /var/run
|
||||
- /tmp
|
||||
|
||||
seed:
|
||||
image: proxmox-api-simulator:0.1.0
|
||||
networks: [hx]
|
||||
environment:
|
||||
<<: *simulator-env
|
||||
SEED_PROFILE: ${SEED_PROFILE:-small}
|
||||
depends_on:
|
||||
simulator:
|
||||
condition: service_healthy
|
||||
entrypoint: ["python"]
|
||||
command: ["-m", "app.simulation.seed_cli"]
|
||||
restart: "no"
|
||||
|
||||
pulumi-runner:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile.pulumi-runner
|
||||
image: hx-pulumi-runner:local
|
||||
networks: [hx]
|
||||
working_dir: /workspace/pulumi-tests
|
||||
volumes:
|
||||
- ../..:/workspace
|
||||
- pulumi-state:/workspace/pulumi-tests/pulumi/.pulumi-state
|
||||
environment:
|
||||
<<: *test-env
|
||||
PULUMI_CONFIG_PASSPHRASE: hx-test-passphrase
|
||||
PULUMI_BACKEND_URL: file:///workspace/pulumi-tests/pulumi/.pulumi-state
|
||||
depends_on:
|
||||
seed:
|
||||
condition: service_completed_successfully
|
||||
tls-gateway:
|
||||
condition: service_healthy
|
||||
profiles: [pulumi]
|
||||
entrypoint: ["python"]
|
||||
command:
|
||||
[
|
||||
"pulumi/run_suite.py",
|
||||
"--report-html",
|
||||
"pulumi/reports/report.html",
|
||||
"--report-json",
|
||||
"pulumi/reports/results.json",
|
||||
"--report-junit",
|
||||
"pulumi/reports/junit.xml",
|
||||
]
|
||||
|
||||
cleanup:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile.pulumi-runner
|
||||
image: hx-pulumi-runner:local
|
||||
networks: [hx]
|
||||
working_dir: /workspace/pulumi-tests
|
||||
volumes:
|
||||
- ../..:/workspace
|
||||
environment:
|
||||
<<: *test-env
|
||||
depends_on:
|
||||
simulator:
|
||||
condition: service_healthy
|
||||
profiles: [cleanup]
|
||||
entrypoint: ["python"]
|
||||
command: ["tools/cleanup_test_resources.py"]
|
||||
@@ -0,0 +1,3 @@
|
||||
encryptionsalt: v1:XH46KobfYos=:v1:tQDcaIs9vVMnsfrW:b9Gi6Lg3VJtY1sIwqiebZuWZnUs2JQ==
|
||||
config:
|
||||
hx-lifecycle:smoke: "0"
|
||||
@@ -0,0 +1,3 @@
|
||||
encryptionsalt: v1:A8xu+B2cPSM=:v1:pyNe+LdXmI8S/syj:y55+cE0uCXlcqMIU9QH3lcy2VdM0/g==
|
||||
config:
|
||||
hx-lifecycle:smoke: "0"
|
||||
@@ -0,0 +1,3 @@
|
||||
encryptionsalt: v1:UzHEGVVb7Hg=:v1:wFaQ/0fPsClXx/FH:If0c+b7X1v2JlAsjgqw3VbiEixE4WQ==
|
||||
config:
|
||||
hx-lifecycle:smoke: "0"
|
||||
@@ -0,0 +1,3 @@
|
||||
encryptionsalt: v1:T7d8B72/cgw=:v1:JQTIJDuIttxYRG3P:dkgjqexvURaSUfheXRFBnnhk4gShAg==
|
||||
config:
|
||||
hx-lifecycle:smoke: "0"
|
||||
@@ -0,0 +1,3 @@
|
||||
encryptionsalt: v1:os3oQmc2a3w=:v1:ucPclEUo9WaE7yPN:HC3Vd0B6beGLxZVvOI9J9GO5BHdwCQ==
|
||||
config:
|
||||
hx-lifecycle:smoke: "0"
|
||||
@@ -0,0 +1,3 @@
|
||||
encryptionsalt: v1:cp9iAk4kDHE=:v1:F/B2BBJxO0k5ZUA5:DTlM5vdnm0ZCzhBdO0PeNwVdutV5yg==
|
||||
config:
|
||||
hx-lifecycle:smoke: "0"
|
||||
@@ -0,0 +1,3 @@
|
||||
encryptionsalt: v1:2+s2B58ijdg=:v1:1Kp9yO99F2P3ge1T:kGUur8A2sLlkUZ4oHzqi8FtnQaAGAg==
|
||||
config:
|
||||
hx-lifecycle:smoke: "0"
|
||||
@@ -0,0 +1,3 @@
|
||||
encryptionsalt: v1:iFaG0Vq94ro=:v1:tk3lV99BGpFbDimz:n1hcd1LrIDd5UOfPp3Zr3VBZjWd59Q==
|
||||
config:
|
||||
hx-lifecycle:smoke: "0"
|
||||
@@ -0,0 +1,3 @@
|
||||
encryptionsalt: v1:erx+K9e1Euc=:v1:AdiBWzU1hqyX6uoG:BybNBCL5DmFOBYT2bMZXpYcRPqgvow==
|
||||
config:
|
||||
hx-lifecycle:smoke: "0"
|
||||
@@ -0,0 +1,3 @@
|
||||
encryptionsalt: v1:72zO/F62ADI=:v1:1eWLQhjIh/pym+XQ:acoyK9LVJRwiQti6/ISvHEEY5b949Q==
|
||||
config:
|
||||
hx-lifecycle:smoke: "0"
|
||||
@@ -0,0 +1,3 @@
|
||||
encryptionsalt: v1:aJXNBbnMxrc=:v1:b3PW3ve+9nVeIQzL:8aERP/Q0sfJdxfzCpp036++baWcIPw==
|
||||
config:
|
||||
hx-lifecycle:smoke: "0"
|
||||
@@ -0,0 +1,3 @@
|
||||
encryptionsalt: v1:FncbMOoIrxc=:v1:SQhsQbyz402+sdJr:eLVKSgilS1OXn3h/akvFMUl9Hn/SQQ==
|
||||
config:
|
||||
hx-lifecycle:smoke: "0"
|
||||
@@ -0,0 +1,3 @@
|
||||
encryptionsalt: v1:i+BOW7Km3mY=:v1:+fV2zYTynoK3+SSe:bv6NNjPsbqdf+xpIPB8FjuglWAfEjQ==
|
||||
config:
|
||||
hx-lifecycle:smoke: "0"
|
||||
@@ -0,0 +1,3 @@
|
||||
name: hx-lifecycle
|
||||
runtime: python
|
||||
description: Compact Proxmox simulator lifecycle scenarios
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Lifecycle suite driven primarily by pulumi-proxmoxve (BPG bridge).
|
||||
|
||||
Surface probing of every contract method stays in ``run_suite.py`` (HTTP).
|
||||
This program covers provider-backed inventory + VM lifecycle with non-empty
|
||||
output checks. Negative auth still uses httpx against the plain HTTP API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import pulumi
|
||||
import pulumi_proxmoxve as proxmox
|
||||
from pvelib.api import Pve
|
||||
|
||||
cfg = pulumi.Config()
|
||||
smoke = (cfg.get("smoke") or os.environ.get("SMOKE_ONLY") or "0") == "1"
|
||||
|
||||
node = os.environ.get("PVE_NODE", "pve01")
|
||||
endpoint = (
|
||||
os.environ.get("PROXMOX_VE_ENDPOINT") or "https://tls-gateway:8443/"
|
||||
).rstrip("/") + "/"
|
||||
username = os.environ.get("PROXMOX_VE_USERNAME") or os.environ.get("API_USER", "root@pam")
|
||||
password = os.environ.get("PROXMOX_VE_PASSWORD") or os.environ.get("API_PASSWORD", "secret")
|
||||
insecure = (os.environ.get("PROXMOX_VE_INSECURE") or "true").lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
}
|
||||
|
||||
|
||||
def _vmid(tag: str) -> int:
|
||||
digest = hashlib.sha1(f"hx-pve-{tag}-{os.getpid()}".encode()).hexdigest()
|
||||
return 710000 + (int(digest[:6], 16) % 90000)
|
||||
|
||||
|
||||
def _require(value: Any, label: str) -> Any:
|
||||
if value is None:
|
||||
raise AssertionError(f"{label}: value is None")
|
||||
if isinstance(value, (str, bytes)) and not str(value).strip():
|
||||
raise AssertionError(f"{label}: empty string")
|
||||
if isinstance(value, (list, tuple, set, dict)) and len(value) == 0:
|
||||
raise AssertionError(f"{label}: empty {type(value).__name__}")
|
||||
return value
|
||||
|
||||
|
||||
def _datastore_ids(datastores: Any) -> list[str]:
|
||||
items = getattr(datastores, "datastores", None) or datastores or []
|
||||
ids: list[str] = []
|
||||
for item in items:
|
||||
if isinstance(item, dict):
|
||||
value = item.get("id") or item.get("datastore_id") or item.get("storage")
|
||||
else:
|
||||
value = (
|
||||
getattr(item, "id", None)
|
||||
or getattr(item, "datastore_id", None)
|
||||
or getattr(item, "storage", None)
|
||||
)
|
||||
if value not in (None, ""):
|
||||
ids.append(str(value))
|
||||
return ids
|
||||
|
||||
|
||||
provider = proxmox.Provider(
|
||||
"proxmoxve",
|
||||
endpoint=endpoint,
|
||||
username=username,
|
||||
password=password,
|
||||
insecure=insecure,
|
||||
)
|
||||
prov_opts = pulumi.ResourceOptions(provider=provider)
|
||||
invoke_opts = pulumi.InvokeOptions(provider=provider)
|
||||
|
||||
# --- Provider data sources (inventory) ---
|
||||
version = proxmox.get_version_legacy(opts=invoke_opts)
|
||||
identity = version.version or version.release or version.repository_id
|
||||
_require(identity, "get_version_legacy identity")
|
||||
|
||||
nodes = proxmox.get_nodes_legacy(opts=invoke_opts)
|
||||
node_names = list(_require(nodes.names, "get_nodes_legacy.names"))
|
||||
if node not in node_names:
|
||||
raise AssertionError(f"expected node {node!r} in {node_names!r}")
|
||||
|
||||
stores = proxmox.get_datastores_legacy(node_name=node, opts=invoke_opts)
|
||||
store_ids = _require(_datastore_ids(stores), "get_datastores_legacy ids")
|
||||
|
||||
# --- Negative auth via HTTP ---
|
||||
_bad = Pve(authenticate=False)
|
||||
try:
|
||||
try:
|
||||
_bad.login(password="definitely-wrong")
|
||||
raise AssertionError("expected login failure for bad password")
|
||||
except AssertionError:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
_bad.close()
|
||||
|
||||
# --- VM via pulumi-proxmoxve (mirrors examples/terraform cookbook shape) ---
|
||||
vm_id = _vmid("qemu")
|
||||
vm_name = f"hxpu{vm_id}"
|
||||
|
||||
vm = proxmox.VmLegacy(
|
||||
"hx-vm",
|
||||
name=vm_name,
|
||||
node_name=node,
|
||||
vm_id=vm_id,
|
||||
started=False,
|
||||
on_boot=False,
|
||||
agent={"enabled": False},
|
||||
cpu={"cores": 1 if smoke else 2},
|
||||
memory={"dedicated": 512 if smoke else 1024},
|
||||
opts=prov_opts,
|
||||
)
|
||||
|
||||
|
||||
def _check_vm(args: list[Any]) -> dict[str, Any]:
|
||||
name, vmid, node_name = args
|
||||
_require(name, "VmLegacy.name")
|
||||
_require(vmid, "VmLegacy.vm_id")
|
||||
_require(node_name, "VmLegacy.node_name")
|
||||
if str(name) != vm_name:
|
||||
raise AssertionError(f"name mismatch {name!r} != {vm_name!r}")
|
||||
if int(vmid) != vm_id:
|
||||
raise AssertionError(f"vmid mismatch {vmid!r} != {vm_id!r}")
|
||||
if str(node_name) != node:
|
||||
raise AssertionError(f"node mismatch {node_name!r} != {node!r}")
|
||||
return {"name": str(name), "vm_id": int(vmid), "node": str(node_name)}
|
||||
|
||||
|
||||
vm_checked = pulumi.Output.all(vm.name, vm.vm_id, vm.node_name).apply(_check_vm)
|
||||
|
||||
scenario_ids = [
|
||||
"provider_version",
|
||||
"provider_nodes",
|
||||
"provider_datastores",
|
||||
"auth_bad_password",
|
||||
"vm_lifecycle_provider",
|
||||
]
|
||||
|
||||
pulumi.export(
|
||||
"inventory",
|
||||
{
|
||||
"version": str(identity),
|
||||
"nodes": node_names,
|
||||
"datastores": store_ids,
|
||||
},
|
||||
)
|
||||
pulumi.export("vm", vm_checked)
|
||||
pulumi.export("scenario_ids", scenario_ids)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Pulumi-side Proxmox helpers (httpx)."""
|
||||
|
||||
from .api import Pve
|
||||
|
||||
__all__ = ["Pve"]
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Minimal Proxmox API client for Pulumi programs and surface probes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
import urllib.parse
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class Pve:
|
||||
def __init__(self, *, authenticate: bool = True) -> None:
|
||||
root = os.environ.get("API_URL", "http://simulator:8006").rstrip("/")
|
||||
self.root = root
|
||||
self.base = root + "/api2/json"
|
||||
self.node = os.environ.get("PVE_NODE", "pve01")
|
||||
self.storage = os.environ.get("PVE_STORAGE", "local-lvm")
|
||||
self.bridge = os.environ.get("PVE_BRIDGE", "vmbr0")
|
||||
self._c = httpx.Client(base_url=self.base, timeout=120.0)
|
||||
self._h: dict[str, str] = {}
|
||||
if authenticate:
|
||||
self.login()
|
||||
|
||||
def close(self) -> None:
|
||||
self._c.close()
|
||||
|
||||
def __enter__(self) -> Pve:
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: object) -> None:
|
||||
self.close()
|
||||
|
||||
def login(
|
||||
self,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
r = self._c.post(
|
||||
"/access/ticket",
|
||||
data={
|
||||
"username": username or os.environ.get("API_USER", "root@pam"),
|
||||
"password": password or os.environ.get("API_PASSWORD", "secret"),
|
||||
},
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()["data"]
|
||||
self._h = {
|
||||
"Cookie": f"PVEAuthCookie={data['ticket']}",
|
||||
"CSRFPreventionToken": data["CSRFPreventionToken"],
|
||||
}
|
||||
return data
|
||||
|
||||
def clear_auth(self) -> None:
|
||||
self._h = {}
|
||||
|
||||
def req(self, method: str, path: str, **kw: Any) -> Any:
|
||||
r = self._c.request(method, path, headers=self._h, **kw)
|
||||
if r.status_code >= 400:
|
||||
raise RuntimeError(f"{method} {path} -> {r.status_code} {r.text}")
|
||||
return r.json().get("data")
|
||||
|
||||
def wait(self, upid: str) -> None:
|
||||
enc = urllib.parse.quote(upid, safe="")
|
||||
for _ in range(600):
|
||||
st = self.req("GET", f"/nodes/{self.node}/tasks/{enc}/status")
|
||||
if st.get("status") == "stopped":
|
||||
if st.get("exitstatus") != "OK":
|
||||
raise RuntimeError(st)
|
||||
return
|
||||
time.sleep(0.2)
|
||||
raise TimeoutError(upid)
|
||||
|
||||
def create_vm(self, vmid: int, name: str) -> None:
|
||||
upid = self.req(
|
||||
"POST",
|
||||
f"/nodes/{self.node}/qemu",
|
||||
data={
|
||||
"vmid": vmid,
|
||||
"name": name,
|
||||
"cores": 1,
|
||||
"memory": 512,
|
||||
"scsi0": f"{self.storage}:vm-{vmid}-disk-0,size=8G",
|
||||
"net0": f"virtio,bridge={self.bridge}",
|
||||
},
|
||||
)
|
||||
self.wait(upid)
|
||||
|
||||
def delete_vm(self, vmid: int) -> None:
|
||||
try:
|
||||
upid = self.req("POST", f"/nodes/{self.node}/qemu/{vmid}/status/stop")
|
||||
if isinstance(upid, str):
|
||||
self.wait(upid)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
upid = self.req("DELETE", f"/nodes/{self.node}/qemu/{vmid}")
|
||||
if isinstance(upid, str):
|
||||
self.wait(upid)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def create_lxc(self, vmid: int, name: str) -> None:
|
||||
upid = self.req(
|
||||
"POST",
|
||||
f"/nodes/{self.node}/lxc",
|
||||
data={
|
||||
"vmid": vmid,
|
||||
"hostname": name,
|
||||
"ostemplate": "local:vztmpl/example.tar.zst",
|
||||
"rootfs": f"{self.storage}:8",
|
||||
"memory": 512,
|
||||
"cores": 1,
|
||||
"net0": f"name=eth0,bridge={self.bridge},ip=dhcp",
|
||||
},
|
||||
)
|
||||
self.wait(upid)
|
||||
|
||||
def delete_lxc(self, vmid: int) -> None:
|
||||
try:
|
||||
upid = self.req("POST", f"/nodes/{self.node}/lxc/{vmid}/status/stop")
|
||||
if isinstance(upid, str):
|
||||
self.wait(upid)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
upid = self.req("DELETE", f"/nodes/{self.node}/lxc/{vmid}")
|
||||
if isinstance(upid, str):
|
||||
self.wait(upid)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,353 @@
|
||||
"""Sync contract surface probe against a live simulator (majors 6–9).
|
||||
|
||||
Mirrors ``app/surface_probe.py`` classification and path/body synthesis, but
|
||||
talks HTTP to ``API_URL`` instead of an in-process ASGI app.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
# Bundled revisions — keep in sync with app/web/contract_catalog.py
|
||||
MAJOR_REVISIONS: dict[int, tuple[str, str]] = {
|
||||
6: ("6.4-15", "96cd7121e75cdb3efd58f79ca988f6b235a2f28e6f7eae276ae243f65d8a6724"),
|
||||
7: ("7.4-16", "2cf632fa6ea4939ca9cb7998ade688150db25b0684600f53ac0ca95730f1d99f"),
|
||||
8: ("8.4.5", "fce6db0a784b3a9b447895895fc6ff4b4437c2dce82e5f3db99227af217726fa"),
|
||||
9: ("9.2.3", "e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1"),
|
||||
}
|
||||
|
||||
_PATH_RE = re.compile(r"\{([^{}]+)\}")
|
||||
_FORBIDDEN = re.compile(
|
||||
r"not supported in the emulator|not implemented in the simulator|"
|
||||
r"handler pending for this contract method|is not supported in the (emulator|simulator)",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_PATH_PARAM_EXAMPLES: dict[str, object] = {
|
||||
"node": "pve01",
|
||||
"vmid": 100,
|
||||
"storage": "local",
|
||||
"pool": "testpool",
|
||||
"userid": "root@pam",
|
||||
"tokenid": "automation",
|
||||
"realm": "pam",
|
||||
"group": "admins",
|
||||
"role": "Administrator",
|
||||
"upid": "UPID:pve01:00000001:00000001:65000001:qmstart:100:root@pam:",
|
||||
"snapname": "snap1",
|
||||
"volume": "local:100/vm-100-disk-0.qcow2",
|
||||
"disk": "scsi0",
|
||||
"iface": "net0",
|
||||
"key": "cpu",
|
||||
"digest": "00000000",
|
||||
"name": "example",
|
||||
}
|
||||
|
||||
_EXTRA_PATH: dict[str, object] = {
|
||||
"groupid": "admins",
|
||||
"roleid": "Administrator",
|
||||
"zone": "localnet",
|
||||
"vnet": "vnet0",
|
||||
"subnet": "10.0.0.0-24",
|
||||
"controller": "evpn1",
|
||||
"dns": "dns1",
|
||||
"ipam": "pve",
|
||||
"flag": "noout",
|
||||
"osdid": "0",
|
||||
"monid": "0",
|
||||
"id": "example",
|
||||
"cputype": "custom1",
|
||||
"pci-id-or-mapping": "0000:00:1f.0",
|
||||
"rule": "rule1",
|
||||
"sid": "vm:100",
|
||||
"pos": "0",
|
||||
"cidr": "10.0.0.0/24",
|
||||
"tokenid": "automation",
|
||||
"fabric_id": "fab1",
|
||||
"node_id": "pve01",
|
||||
"url_seq": "1",
|
||||
"route-map-id": "rm1",
|
||||
"order": "10",
|
||||
"userid": "root@pam",
|
||||
"realm": "pam",
|
||||
"name": "example",
|
||||
"plugin": "example",
|
||||
"target": "example",
|
||||
}
|
||||
|
||||
CRITICAL_BUCKETS = frozenset(
|
||||
{"unimplemented_501", "unsupported_message", "server_5xx", "exception"}
|
||||
)
|
||||
|
||||
|
||||
def contracts_root() -> Path:
|
||||
env = os.environ.get("CONTRACTS_ROOT")
|
||||
if env:
|
||||
return Path(env)
|
||||
# Runner mounts repo at /workspace; local runs use repo-relative path.
|
||||
here = Path(__file__).resolve()
|
||||
candidates = [
|
||||
Path("/workspace/contracts"),
|
||||
here.parents[3] / "contracts", # pulumi-tests/pulumi/pvelib → repo
|
||||
Path.cwd() / "contracts",
|
||||
Path.cwd().parent / "contracts",
|
||||
]
|
||||
for path in candidates:
|
||||
if path.is_dir():
|
||||
return path
|
||||
raise FileNotFoundError("contracts/ directory not found; set CONTRACTS_ROOT")
|
||||
|
||||
|
||||
def load_snapshot(major: int) -> dict[str, Any]:
|
||||
version, revision = MAJOR_REVISIONS[major]
|
||||
path = contracts_root() / revision / "snapshot.json"
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if data.get("source_version") != version:
|
||||
# Still usable; keep declared version from manifest pairing.
|
||||
pass
|
||||
return data
|
||||
|
||||
|
||||
def _path_value(name: str) -> str:
|
||||
value = _PATH_PARAM_EXAMPLES.get(name)
|
||||
if value is None:
|
||||
value = _EXTRA_PATH.get(name, "example")
|
||||
return str(value)
|
||||
|
||||
|
||||
def render_path(template: str) -> str:
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
return quote(str(_path_value(match.group(1))), safe="@._-")
|
||||
|
||||
return _PATH_RE.sub(replace, template)
|
||||
|
||||
|
||||
def _schema_example(schema: dict[str, Any] | None, *, name: str | None = None) -> Any:
|
||||
if not schema:
|
||||
return "example"
|
||||
if schema.get("default") is not None:
|
||||
return schema["default"]
|
||||
enum = schema.get("enum") or []
|
||||
if enum:
|
||||
return enum[0]
|
||||
if name is not None:
|
||||
hinted = _PATH_PARAM_EXAMPLES.get(name)
|
||||
if hinted is not None:
|
||||
return hinted
|
||||
typ = schema.get("type")
|
||||
if typ == "array":
|
||||
items = schema.get("items")
|
||||
return [_schema_example(items)] if items else []
|
||||
if typ == "object":
|
||||
props = schema.get("properties") or {}
|
||||
return {
|
||||
key: _schema_example(defn, name=key)
|
||||
for key, defn in props.items()
|
||||
if not (isinstance(defn, dict) and defn.get("optional"))
|
||||
}
|
||||
if typ == "boolean":
|
||||
return False
|
||||
if typ == "integer":
|
||||
minimum = schema.get("minimum")
|
||||
return int(minimum) if minimum is not None else 1
|
||||
if typ == "number":
|
||||
minimum = schema.get("minimum")
|
||||
return float(minimum) if minimum is not None else 1.0
|
||||
return "example"
|
||||
|
||||
|
||||
def body_for(method: dict[str, Any], path_template: str) -> dict[str, Any]:
|
||||
path_names = set(_PATH_RE.findall(path_template))
|
||||
payload: dict[str, Any] = {}
|
||||
for parameter in method.get("parameters") or []:
|
||||
name = parameter.get("name")
|
||||
if not name or name in path_names:
|
||||
continue
|
||||
definition = parameter.get("definition") or {}
|
||||
if definition.get("optional"):
|
||||
continue
|
||||
payload[name] = _schema_example(definition, name=name)
|
||||
return payload
|
||||
|
||||
|
||||
def classify(status: int, text: str) -> str:
|
||||
if _FORBIDDEN.search(text or ""):
|
||||
return "unsupported_message"
|
||||
if status == 501:
|
||||
return "unimplemented_501"
|
||||
if 200 <= status < 300:
|
||||
return "success_2xx"
|
||||
if status in {401, 403}:
|
||||
return "auth_401_403"
|
||||
if status in {400, 404, 405, 409, 412, 422, 423}:
|
||||
return "client_4xx"
|
||||
if status >= 500:
|
||||
return "server_5xx"
|
||||
return f"other_{status}"
|
||||
|
||||
|
||||
def probe_major(
|
||||
client: httpx.Client,
|
||||
csrf: str,
|
||||
major: int,
|
||||
snapshot: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
started = time.monotonic()
|
||||
apply = client.post("/ui/api/contract/apply", params={"major": major})
|
||||
apply.raise_for_status()
|
||||
applied = apply.json()
|
||||
|
||||
by_verb: dict[str, Counter[str]] = defaultdict(Counter)
|
||||
failures: list[dict[str, Any]] = []
|
||||
method_results: list[dict[str, Any]] = []
|
||||
|
||||
methods: list[tuple[str, dict[str, Any]]] = [
|
||||
(path["path"], method)
|
||||
for path in snapshot.get("paths") or []
|
||||
for method in path.get("methods") or []
|
||||
]
|
||||
order = {"GET": 0, "PUT": 1, "POST": 2, "DELETE": 3}
|
||||
methods.sort(key=lambda item: (order.get(str(item[1].get("verb", "")).upper(), 9), item[0]))
|
||||
|
||||
for path_template, method in methods:
|
||||
verb = str(method.get("verb", "")).upper()
|
||||
url = f"/api2/json{render_path(path_template)}"
|
||||
headers = {"CSRFPreventionToken": csrf} if verb != "GET" else {}
|
||||
params = body_for(method, path_template)
|
||||
try:
|
||||
if verb == "GET":
|
||||
response = client.get(url, headers=headers, params=params or None)
|
||||
elif verb == "PUT":
|
||||
response = client.put(url, data=params or {}, headers=headers)
|
||||
elif verb == "POST":
|
||||
response = client.post(url, data=params or {}, headers=headers)
|
||||
elif verb == "DELETE":
|
||||
# Proxmox accepts delete identifiers as form or query params.
|
||||
response = client.request(
|
||||
"DELETE", url, data=params or {}, headers=headers
|
||||
)
|
||||
else:
|
||||
by_verb[verb or "UNKNOWN"]["exception"] += 1
|
||||
item = {
|
||||
"verb": verb or "UNKNOWN",
|
||||
"path": path_template,
|
||||
"error": f"unsupported HTTP verb {verb!r}",
|
||||
"bucket": "exception",
|
||||
"ok": False,
|
||||
}
|
||||
failures.append(item)
|
||||
method_results.append(item)
|
||||
continue
|
||||
except Exception as exc: # noqa: BLE001
|
||||
by_verb[verb]["exception"] += 1
|
||||
item = {
|
||||
"verb": verb,
|
||||
"path": path_template,
|
||||
"error": str(exc)[:200],
|
||||
"bucket": "exception",
|
||||
"ok": False,
|
||||
}
|
||||
failures.append(item)
|
||||
method_results.append(item)
|
||||
continue
|
||||
|
||||
text = response.text
|
||||
bucket = classify(response.status_code, text)
|
||||
by_verb[verb][bucket] += 1
|
||||
ok = bucket not in CRITICAL_BUCKETS
|
||||
item = {
|
||||
"verb": verb,
|
||||
"path": path_template,
|
||||
"status": response.status_code,
|
||||
"bucket": bucket,
|
||||
"ok": ok,
|
||||
}
|
||||
if not ok:
|
||||
item["body"] = text[:240]
|
||||
failures.append(item)
|
||||
method_results.append(item)
|
||||
|
||||
version, _ = MAJOR_REVISIONS[major]
|
||||
declared = int(snapshot.get("method_count") or len(method_results))
|
||||
critical = len(failures)
|
||||
success_2xx = sum(c.get("success_2xx", 0) for c in by_verb.values())
|
||||
probed = len(method_results)
|
||||
empty_reasons: list[str] = []
|
||||
if not isinstance(applied, dict) or not applied:
|
||||
empty_reasons.append("apply response empty")
|
||||
elif applied.get("ok") is False:
|
||||
empty_reasons.append(f"apply not ok: {applied!r}")
|
||||
if declared <= 0:
|
||||
empty_reasons.append("declared method_count is 0")
|
||||
if probed <= 0:
|
||||
empty_reasons.append("no methods probed")
|
||||
if probed != declared:
|
||||
empty_reasons.append(
|
||||
f"incomplete coverage: probed {probed} of {declared} declared methods"
|
||||
)
|
||||
if success_2xx <= 0:
|
||||
empty_reasons.append("success_2xx count is 0")
|
||||
if empty_reasons:
|
||||
for reason in empty_reasons:
|
||||
failures.append(
|
||||
{
|
||||
"verb": "META",
|
||||
"path": f"major/{major}",
|
||||
"bucket": "exception",
|
||||
"ok": False,
|
||||
"error": reason,
|
||||
}
|
||||
)
|
||||
critical = len(failures)
|
||||
|
||||
return {
|
||||
"major": major,
|
||||
"version": snapshot.get("source_version") or version,
|
||||
"apply": applied,
|
||||
"declared": declared,
|
||||
"probed": probed,
|
||||
"by_verb": {verb: dict(counter) for verb, counter in by_verb.items()},
|
||||
"success_2xx": success_2xx,
|
||||
"client_4xx": sum(
|
||||
c.get("client_4xx", 0) + c.get("auth_401_403", 0) for c in by_verb.values()
|
||||
),
|
||||
"failure_count": critical,
|
||||
"failures": failures,
|
||||
"ok": critical == 0,
|
||||
"time": time.monotonic() - started,
|
||||
"methods": method_results,
|
||||
}
|
||||
|
||||
|
||||
def run_surface(
|
||||
*,
|
||||
majors: tuple[int, ...] = (6, 7, 8, 9),
|
||||
api_url: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
base = (api_url or os.environ.get("API_URL", "http://simulator:8006")).rstrip("/")
|
||||
user = os.environ.get("API_USER", "root@pam")
|
||||
password = os.environ.get("API_PASSWORD", "secret")
|
||||
results: list[dict[str, Any]] = []
|
||||
with httpx.Client(base_url=base, timeout=60.0) as client:
|
||||
# Prefer env credentials over hard-coded login body.
|
||||
ticket = client.post(
|
||||
"/api2/json/access/ticket",
|
||||
data={"username": user, "password": password},
|
||||
)
|
||||
ticket.raise_for_status()
|
||||
data = ticket.json()["data"]
|
||||
client.cookies.set("PVEAuthCookie", data["ticket"])
|
||||
csrf = str(data["CSRFPreventionToken"])
|
||||
for major in majors:
|
||||
snapshot = load_snapshot(major)
|
||||
results.append(probe_major(client, csrf, major, snapshot))
|
||||
return results
|
||||
@@ -0,0 +1,265 @@
|
||||
"""Render HTML and optional JUnit reports for the Pulumi HX suite."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from xml.etree.ElementTree import Element, ElementTree, SubElement
|
||||
|
||||
|
||||
def write_json(payload: dict[str, Any], path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Drop per-method lists from majors for a lighter default JSON? Keep full for debugging.
|
||||
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
|
||||
def write_junit(payload: dict[str, Any], path: Path) -> None:
|
||||
surface = payload.get("surface") or []
|
||||
scenarios = payload.get("scenarios") or []
|
||||
cases: list[dict[str, Any]] = []
|
||||
for major in surface:
|
||||
cases.append(
|
||||
{
|
||||
"classname": "surface",
|
||||
"name": f"PVE {major.get('version')} major={major.get('major')}",
|
||||
"time": major.get("time") or 0,
|
||||
"ok": bool(major.get("ok")),
|
||||
"error": _surface_error(major),
|
||||
}
|
||||
)
|
||||
for item in scenarios:
|
||||
cases.append(
|
||||
{
|
||||
"classname": "lifecycle",
|
||||
"name": item.get("id") or item.get("name") or "lifecycle",
|
||||
"time": item.get("time") or 0,
|
||||
"ok": bool(item.get("ok")),
|
||||
"error": item.get("error") or "",
|
||||
}
|
||||
)
|
||||
|
||||
suite = Element(
|
||||
"testsuite",
|
||||
name="pulumi-hx",
|
||||
tests=str(len(cases)),
|
||||
failures=str(sum(1 for c in cases if not c["ok"])),
|
||||
time=f"{sum(float(c['time']) for c in cases):.3f}",
|
||||
)
|
||||
for item in cases:
|
||||
case = SubElement(
|
||||
suite,
|
||||
"testcase",
|
||||
classname=str(item["classname"]),
|
||||
name=str(item["name"]),
|
||||
time=f"{float(item['time']):.3f}",
|
||||
)
|
||||
if not item["ok"]:
|
||||
failure = SubElement(case, "failure", message=str(item["error"])[:500])
|
||||
failure.text = str(item["error"])
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
ElementTree(suite).write(path, encoding="utf-8", xml_declaration=True)
|
||||
|
||||
|
||||
def _surface_error(major: dict[str, Any]) -> str:
|
||||
fails = major.get("failures") or []
|
||||
if not fails:
|
||||
return ""
|
||||
parts = [
|
||||
f"{f.get('verb')} {f.get('path')} -> {f.get('bucket')} {f.get('status', '')}"
|
||||
for f in fails[:20]
|
||||
]
|
||||
return f"{len(fails)} critical: " + "; ".join(parts)
|
||||
|
||||
|
||||
def write_html(payload: dict[str, Any], path: Path) -> None:
|
||||
surface = payload.get("surface") or []
|
||||
scenarios = payload.get("scenarios") or []
|
||||
coverage = payload.get("coverage") or {}
|
||||
ok = bool(payload.get("ok"))
|
||||
elapsed = float(payload.get("elapsed") or 0)
|
||||
|
||||
coverage_by_major = coverage.get("by_major") or []
|
||||
coverage_rows = []
|
||||
for item in coverage_by_major:
|
||||
complete = bool(item.get("complete"))
|
||||
coverage_rows.append(
|
||||
"<tr>"
|
||||
f"<td>{html.escape(str(item.get('major')))}</td>"
|
||||
f"<td>{html.escape(str(item.get('version')))}</td>"
|
||||
f"<td>{html.escape(str(item.get('declared')))}</td>"
|
||||
f"<td>{html.escape(str(item.get('probed')))}</td>"
|
||||
f"<td>{html.escape(str(item.get('critical')))}</td>"
|
||||
f"<td class='{'ok' if complete else 'fail'}'>"
|
||||
f"{'yes' if complete else 'no'}</td>"
|
||||
"</tr>"
|
||||
)
|
||||
declared_total = int(coverage.get("declared_total") or 0)
|
||||
probed_total = int(coverage.get("probed_total") or 0)
|
||||
critical_total = int(coverage.get("critical_total") or 0)
|
||||
coverage_ok = bool(coverage.get("ok")) if coverage_by_major else True
|
||||
majs = coverage.get("majors") or []
|
||||
if len(majs) >= 2:
|
||||
majors_label = f"{majs[0]}–{majs[-1]}"
|
||||
elif majs:
|
||||
majors_label = str(majs[0])
|
||||
else:
|
||||
majors_label = "—"
|
||||
|
||||
surface_rows = []
|
||||
failure_rows = []
|
||||
for major in surface:
|
||||
surface_rows.append(
|
||||
"<tr>"
|
||||
f"<td>{html.escape(str(major.get('major')))}</td>"
|
||||
f"<td>{html.escape(str(major.get('version')))}</td>"
|
||||
f"<td>{html.escape(str(major.get('declared')))}</td>"
|
||||
f"<td>{html.escape(str(major.get('probed')))}</td>"
|
||||
f"<td>{html.escape(str(major.get('success_2xx')))}</td>"
|
||||
f"<td>{html.escape(str(major.get('client_4xx')))}</td>"
|
||||
f"<td class='{'ok' if major.get('ok') else 'fail'}'>"
|
||||
f"{html.escape(str(major.get('failure_count')))}</td>"
|
||||
f"<td>{float(major.get('time') or 0):.1f}s</td>"
|
||||
"</tr>"
|
||||
)
|
||||
for fail in major.get("failures") or []:
|
||||
failure_rows.append(
|
||||
"<tr>"
|
||||
f"<td>{html.escape(str(major.get('version')))}</td>"
|
||||
f"<td>{html.escape(str(fail.get('verb')))}</td>"
|
||||
f"<td><code>{html.escape(str(fail.get('path')))}</code></td>"
|
||||
f"<td>{html.escape(str(fail.get('bucket')))}</td>"
|
||||
f"<td>{html.escape(str(fail.get('status', fail.get('error', ''))))}</td>"
|
||||
f"<td><code>{html.escape(str(fail.get('body', ''))[:200])}</code></td>"
|
||||
"</tr>"
|
||||
)
|
||||
|
||||
scenario_rows = []
|
||||
for item in scenarios:
|
||||
scenario_rows.append(
|
||||
"<tr>"
|
||||
f"<td>{html.escape(str(item.get('id') or item.get('name')))}</td>"
|
||||
f"<td class='{'ok' if item.get('ok') else 'fail'}'>"
|
||||
f"{'PASS' if item.get('ok') else 'FAIL'}</td>"
|
||||
f"<td>{float(item.get('time') or 0):.2f}s</td>"
|
||||
f"<td><code>{html.escape(str(item.get('error') or ''))}</code></td>"
|
||||
"</tr>"
|
||||
)
|
||||
|
||||
status = "PASS" if ok else "FAIL"
|
||||
status_class = "ok" if ok else "fail"
|
||||
coverage_status = "complete" if coverage_ok else "incomplete"
|
||||
coverage_class = "ok" if coverage_ok else "fail"
|
||||
doc = f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>Pulumi HX suite report</title>
|
||||
<style>
|
||||
:root {{
|
||||
--bg: #0f1419; --panel: #1a222c; --text: #e7ecf1; --muted: #9aa7b5;
|
||||
--ok: #3dd68c; --fail: #ff6b6b; --line: #2a3542;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
--sans: "Segoe UI", system-ui, sans-serif;
|
||||
}}
|
||||
body {{ margin: 0; font-family: var(--sans); background: var(--bg); color: var(--text); }}
|
||||
main {{ max-width: 1100px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }}
|
||||
h1 {{ font-size: 1.6rem; margin: 0 0 .4rem; }}
|
||||
h2 {{ font-size: 1.15rem; margin: 2rem 0 .75rem; }}
|
||||
p.lead {{ color: var(--muted); margin: 0 0 1.5rem; }}
|
||||
.badge {{ display: inline-block; padding: .2rem .6rem; border-radius: .35rem;
|
||||
font-weight: 700; letter-spacing: .02em; }}
|
||||
.badge.ok {{ background: color-mix(in srgb, var(--ok) 25%, transparent); color: var(--ok); }}
|
||||
.badge.fail {{ background: color-mix(in srgb, var(--fail) 25%, transparent); color: var(--fail); }}
|
||||
.cards {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: .75rem; margin: 1rem 0 1.5rem; }}
|
||||
.card {{ background: var(--panel); border: 1px solid var(--line); border-radius: .5rem;
|
||||
padding: .9rem 1rem; }}
|
||||
.card .label {{ color: var(--muted); font-size: .8rem; }}
|
||||
.card .value {{ font-size: 1.35rem; font-weight: 700; margin-top: .25rem; }}
|
||||
table {{ width: 100%; border-collapse: collapse; background: var(--panel);
|
||||
border: 1px solid var(--line); border-radius: .5rem; overflow: hidden; }}
|
||||
th, td {{ text-align: left; padding: .55rem .7rem; border-bottom: 1px solid var(--line);
|
||||
vertical-align: top; font-size: .92rem; }}
|
||||
th {{ color: var(--muted); font-weight: 600; }}
|
||||
tr:last-child td {{ border-bottom: 0; }}
|
||||
td.ok {{ color: var(--ok); font-weight: 600; }}
|
||||
td.fail {{ color: var(--fail); font-weight: 600; }}
|
||||
code {{ font-family: var(--mono); font-size: .85em; }}
|
||||
.empty {{ color: var(--muted); font-style: italic; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Pulumi HX suite report</h1>
|
||||
<p class="lead">Contract surface probe (PVE majors 6–9) + lifecycle scenarios</p>
|
||||
<span class="badge {status_class}">{status}</span>
|
||||
<div class="cards">
|
||||
<div class="card"><div class="label">Elapsed</div>
|
||||
<div class="value">{elapsed:.1f}s</div></div>
|
||||
<div class="card"><div class="label">Majors</div>
|
||||
<div class="value">{len(surface)}</div></div>
|
||||
<div class="card"><div class="label">Critical surface fails</div>
|
||||
<div class="value">{sum(int(m.get('failure_count') or 0) for m in surface)}</div></div>
|
||||
<div class="card"><div class="label">Scenarios</div>
|
||||
<div class="value">{sum(1 for s in scenarios if s.get('ok'))}/{len(scenarios)}</div></div>
|
||||
</div>
|
||||
|
||||
<h2>Full contract coverage</h2>
|
||||
<p class="lead">
|
||||
{probed_total}/{declared_total} methods across majors {html.escape(majors_label)}
|
||||
(critical={critical_total}) —
|
||||
<span class="{coverage_class}">{coverage_status}</span>
|
||||
</p>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Major</th><th>Version</th><th>Declared</th><th>Probed</th>
|
||||
<th>Critical</th><th>declared==probed</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{''.join(coverage_rows) or '<tr><td colspan="6" class="empty">No coverage data</td></tr>'}
|
||||
<tr>
|
||||
<td colspan="2"><strong>Total</strong></td>
|
||||
<td><strong>{declared_total}</strong></td>
|
||||
<td><strong>{probed_total}</strong></td>
|
||||
<td><strong>{critical_total}</strong></td>
|
||||
<td class="{coverage_class}"><strong>{'yes' if coverage_ok else 'no'}</strong></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Surface by major</h2>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Major</th><th>Version</th><th>Declared</th><th>Probed</th>
|
||||
<th>2xx</th><th>4xx/auth</th><th>Critical</th><th>Time</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{''.join(surface_rows) or '<tr><td colspan="8" class="empty">No surface results</td></tr>'}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Critical surface failures</h2>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Version</th><th>Verb</th><th>Path</th><th>Bucket</th><th>Status</th><th>Body</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{''.join(failure_rows) or '<tr><td colspan="6" class="empty">None</td></tr>'}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Lifecycle scenarios</h2>
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>Result</th><th>Time</th><th>Error</th></tr></thead>
|
||||
<tbody>
|
||||
{''.join(scenario_rows) or '<tr><td colspan="4" class="empty">No scenarios</td></tr>'}
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(doc, encoding="utf-8")
|
||||
@@ -0,0 +1,273 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run Pulumi HX suite: full contract surface (majors 6–9) + lifecycle scenarios."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parent
|
||||
PROGRAMS = HERE / "programs"
|
||||
REPORTS = HERE / "reports"
|
||||
|
||||
# Ensure pvelib imports work when invoked as a script.
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
|
||||
def run_lifecycle(*, smoke: bool) -> dict[str, Any]:
|
||||
from pulumi import automation as auto
|
||||
|
||||
program_dir = PROGRAMS / "lifecycle"
|
||||
stack_name = f"hxlife{os.getpid()}{int(time.time())}"
|
||||
os.environ.setdefault("PULUMI_CONFIG_PASSPHRASE", "hx-test-passphrase")
|
||||
state = HERE / ".pulumi-state"
|
||||
state.mkdir(parents=True, exist_ok=True)
|
||||
os.environ.setdefault("PULUMI_BACKEND_URL", f"file://{state}")
|
||||
|
||||
env = {
|
||||
**os.environ,
|
||||
"PYTHONPATH": str(HERE)
|
||||
+ os.pathsep
|
||||
+ str(ROOT)
|
||||
+ os.pathsep
|
||||
+ os.environ.get("PYTHONPATH", ""),
|
||||
}
|
||||
if smoke:
|
||||
env["SMOKE_ONLY"] = "1"
|
||||
|
||||
started = time.monotonic()
|
||||
stack = None
|
||||
try:
|
||||
# Pass env (incl. PULUMI_BACKEND_URL) at workspace creation — assigning
|
||||
# workspace.env_vars after create_or_select_stack can lose stack selection.
|
||||
stack = auto.create_or_select_stack(
|
||||
stack_name=stack_name,
|
||||
work_dir=str(program_dir),
|
||||
opts=auto.LocalWorkspaceOptions(env_vars=env),
|
||||
)
|
||||
stack.set_config("smoke", auto.ConfigValue(value="1" if smoke else "0"))
|
||||
up_result = stack.up(on_output=lambda _: None)
|
||||
outputs = stack.outputs()
|
||||
if not outputs and getattr(up_result, "outputs", None):
|
||||
outputs = up_result.outputs
|
||||
ids: list[str] = []
|
||||
sid_out = outputs.get("scenario_ids")
|
||||
if sid_out is not None and getattr(sid_out, "value", None) is not None:
|
||||
value = sid_out.value
|
||||
if isinstance(value, list):
|
||||
ids = [str(x) for x in value]
|
||||
# Also require inventory / vm exports to be non-empty when present.
|
||||
for key in ("inventory", "vm"):
|
||||
item = outputs.get(key)
|
||||
if item is None or getattr(item, "value", None) in (None, "", {}, []):
|
||||
raise RuntimeError(f"lifecycle export {key!r} is empty")
|
||||
stack.destroy(on_output=lambda _: None)
|
||||
try:
|
||||
stack.workspace.remove_stack(stack_name)
|
||||
except Exception:
|
||||
pass
|
||||
elapsed = time.monotonic() - started
|
||||
if ids:
|
||||
return {
|
||||
"ok": True,
|
||||
"scenarios": [{"id": sid, "ok": True, "error": "", "time": elapsed / max(len(ids), 1)} for sid in ids],
|
||||
"time": elapsed,
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
"scenarios": [{"id": "lifecycle", "ok": True, "error": "", "time": elapsed}],
|
||||
"time": elapsed,
|
||||
}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
detail = str(exc)
|
||||
stderr = getattr(exc, "stderr", None)
|
||||
if stderr:
|
||||
detail = f"{detail}\n{stderr}"
|
||||
if stack is not None:
|
||||
try:
|
||||
stack.destroy(on_output=lambda _: None)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
stack.workspace.remove_stack(stack_name)
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"ok": False,
|
||||
"scenarios": [
|
||||
{
|
||||
"id": "lifecycle",
|
||||
"ok": False,
|
||||
"error": detail,
|
||||
"time": time.monotonic() - started,
|
||||
}
|
||||
],
|
||||
"time": time.monotonic() - started,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--smoke", action="store_true", help="Major 9 surface + short lifecycle")
|
||||
parser.add_argument(
|
||||
"--skip-lifecycle",
|
||||
action="store_true",
|
||||
help="Only run the contract surface probe",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-surface",
|
||||
action="store_true",
|
||||
help="Only run lifecycle scenarios",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--majors",
|
||||
default="",
|
||||
help="Comma-separated majors (default: 6,7,8,9 or 9 with --smoke)",
|
||||
)
|
||||
parser.add_argument("--report-html", default=str(REPORTS / "report.html"))
|
||||
parser.add_argument("--report-json", default=str(REPORTS / "results.json"))
|
||||
parser.add_argument("--report-junit", default=str(REPORTS / "junit.xml"))
|
||||
args = parser.parse_args()
|
||||
|
||||
from pvelib.surface import run_surface
|
||||
from report import write_html, write_json, write_junit
|
||||
|
||||
started = time.monotonic()
|
||||
if args.majors.strip():
|
||||
majors = tuple(int(part.strip()) for part in args.majors.split(",") if part.strip())
|
||||
elif args.smoke:
|
||||
majors = (9,)
|
||||
else:
|
||||
majors = (6, 7, 8, 9)
|
||||
|
||||
surface: list[dict[str, Any]] = []
|
||||
scenarios: list[dict[str, Any]] = []
|
||||
|
||||
# Lifecycle first: surface probing mutates shared auth/config state.
|
||||
if not args.skip_lifecycle:
|
||||
print("Lifecycle scenarios …")
|
||||
life = run_lifecycle(smoke=args.smoke)
|
||||
scenarios = life["scenarios"]
|
||||
for item in scenarios:
|
||||
print(f" {'ok' if item['ok'] else 'FAIL'} {item['id']}")
|
||||
if not item["ok"]:
|
||||
print(item["error"][-2000:], file=sys.stderr)
|
||||
|
||||
if not args.skip_surface:
|
||||
print(f"Surface probe majors={list(majors)} …")
|
||||
surface = run_surface(majors=majors)
|
||||
for item in surface:
|
||||
status = "ok" if item["ok"] else "FAIL"
|
||||
print(
|
||||
f" {status} PVE {item['version']}: declared={item['declared']} "
|
||||
f"probed={item['probed']} critical={item['failure_count']} "
|
||||
f"2xx={item['success_2xx']} 4xx={item['client_4xx']}"
|
||||
)
|
||||
if not item["ok"]:
|
||||
for fail in (item.get("failures") or [])[:10]:
|
||||
print(
|
||||
f" {fail.get('verb')} {fail.get('path')} "
|
||||
f"-> {fail.get('bucket')} {fail.get('status', fail.get('error', ''))}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# Slim JSON: drop full method lists (keep failures)
|
||||
surface_slim = []
|
||||
for item in surface:
|
||||
slim = dict(item)
|
||||
slim.pop("methods", None)
|
||||
surface_slim.append(slim)
|
||||
|
||||
coverage = build_coverage(surface_slim, majors=list(majors))
|
||||
if surface_slim:
|
||||
majs = coverage["majors"]
|
||||
if len(majs) >= 2:
|
||||
major_label = f"{majs[0]}–{majs[-1]}"
|
||||
elif majs:
|
||||
major_label = str(majs[0])
|
||||
else:
|
||||
major_label = "none"
|
||||
print(
|
||||
f"Coverage: {coverage['probed_total']}/{coverage['declared_total']} "
|
||||
f"methods across majors {major_label} "
|
||||
f"(critical={coverage['critical_total']})"
|
||||
)
|
||||
|
||||
# Explicit gate: every major must have declared==probed and zero critical failures.
|
||||
surface_ok = all(
|
||||
int(m.get("declared") or 0) == int(m.get("probed") or 0)
|
||||
and int(m.get("failure_count") or 0) == 0
|
||||
and bool(m.get("ok"))
|
||||
for m in surface_slim
|
||||
)
|
||||
lifecycle_ok = all(bool(s.get("ok")) for s in scenarios)
|
||||
coverage_ok = bool(coverage.get("ok")) if surface_slim else True
|
||||
suite_ok = surface_ok and lifecycle_ok and coverage_ok
|
||||
|
||||
payload = {
|
||||
"ok": suite_ok,
|
||||
"elapsed": time.monotonic() - started,
|
||||
"smoke": args.smoke,
|
||||
"majors": list(majors),
|
||||
"coverage": coverage,
|
||||
"surface": surface_slim,
|
||||
"scenarios": scenarios,
|
||||
}
|
||||
|
||||
write_json(payload, Path(args.report_json))
|
||||
write_html(payload, Path(args.report_html))
|
||||
write_junit(payload, Path(args.report_junit))
|
||||
|
||||
print(
|
||||
f"Suite {'PASS' if payload['ok'] else 'FAIL'} in {payload['elapsed']:.1f}s; "
|
||||
f"html={args.report_html} json={args.report_json}"
|
||||
)
|
||||
return 0 if payload["ok"] else 1
|
||||
|
||||
|
||||
def build_coverage(
|
||||
surface: list[dict[str, Any]],
|
||||
*,
|
||||
majors: list[int] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Aggregate declared/probed/critical counts across surface majors."""
|
||||
by_major: list[dict[str, Any]] = []
|
||||
for item in surface:
|
||||
declared = int(item.get("declared") or 0)
|
||||
probed = int(item.get("probed") or 0)
|
||||
critical = int(item.get("failure_count") or 0)
|
||||
by_major.append(
|
||||
{
|
||||
"major": item.get("major"),
|
||||
"version": item.get("version"),
|
||||
"declared": declared,
|
||||
"probed": probed,
|
||||
"critical": critical,
|
||||
"complete": declared == probed and critical == 0,
|
||||
}
|
||||
)
|
||||
declared_total = sum(m["declared"] for m in by_major)
|
||||
probed_total = sum(m["probed"] for m in by_major)
|
||||
critical_total = sum(m["critical"] for m in by_major)
|
||||
major_ids = [m["major"] for m in by_major if m.get("major") is not None]
|
||||
if not major_ids and majors:
|
||||
major_ids = list(majors)
|
||||
complete = bool(by_major) and all(m["complete"] for m in by_major)
|
||||
return {
|
||||
"majors": major_ids,
|
||||
"declared_total": declared_total,
|
||||
"probed_total": probed_total,
|
||||
"critical_total": critical_total,
|
||||
"by_major": by_major,
|
||||
"ok": complete,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Delete leftover test guests whose names start with the configured prefix."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(HERE / "pulumi"))
|
||||
|
||||
from pvelib.api import Pve # noqa: E402
|
||||
|
||||
|
||||
def main() -> int:
|
||||
prefix = os.environ.get("TEST_RESOURCE_PREFIX", "hx")
|
||||
deleted = 0
|
||||
with Pve() as pve:
|
||||
for kind, delete in (
|
||||
("qemu", pve.delete_vm),
|
||||
("lxc", pve.delete_lxc),
|
||||
):
|
||||
items = pve.req("GET", f"/nodes/{pve.node}/{kind}")
|
||||
if not isinstance(items, list):
|
||||
continue
|
||||
for item in items:
|
||||
name = str(item.get("name") or item.get("hostname") or "")
|
||||
vmid = int(item["vmid"])
|
||||
if not name.startswith(prefix):
|
||||
continue
|
||||
try:
|
||||
delete(vmid)
|
||||
deleted += 1
|
||||
print(f"deleted {kind} vmid={vmid} name={name}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"skip {kind} vmid={vmid}: {exc}", file=sys.stderr)
|
||||
print(f"cleanup complete: deleted={deleted}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user