Fix CI/pulumi blockers and document test suites with latest PASS results.

Stabilize node status, cluster-wide Ceph OSD ids, and QEMU cpu utilization so
status/list no longer 500 on model strings; align pulumi defaults to pve1;
add bilingual testing docs with the 2026-07-18 ci-all + pulumi-tests pass.
This commit is contained in:
Sergey Antropoff
2026-07-18 09:38:28 +03:00
parent 6f42d4c318
commit baa1f58ad0
31 changed files with 230 additions and 58 deletions
+3
View File
@@ -39,6 +39,9 @@ GitHub Actions runs `make ci` plus Compose/Helm validation on every push and PR
to `main`. Run `make ci-all`, `make helm-lint`, and `make pulumi-tests` locally
before larger API or client-facing changes.
Suite layout, Make targets, and the latest recorded local results:
[docs/testing.md](docs/testing.md).
## Project rules worth remembering
1. Mutations must **persist to PostgreSQL** (tables and/or jsonb metadata).
+3
View File
@@ -36,6 +36,9 @@ GitHub Actions на каждый push/PR в `main` запускает `make ci`
Compose/Helm. Перед крупными API/клиентскими изменениями локально прогоняйте
`make ci-all`, `make helm-lint` и `make pulumi-tests`.
Карта наборов, Make-цели и последние записанные локальные результаты:
[docs/ru/testing.md](docs/ru/testing.md).
## Важные правила проекта
1. Мутации должны **persist в PostgreSQL** (таблицы и/или jsonb metadata).
+1
View File
@@ -193,6 +193,7 @@ make release-up && make release-seed # run the published stack locally
## Contributing / security / changelog
- [CONTRIBUTING.md](CONTRIBUTING.md) · [CONTRIBUTING.ru.md](CONTRIBUTING.ru.md)
- [Testing](docs/testing.md) · [Тесты](docs/ru/testing.md) — suites, Make targets, latest results
- [SECURITY.md](SECURITY.md)
- [CHANGELOG.md](CHANGELOG.md)
+1
View File
@@ -194,6 +194,7 @@ make release-up && make release-seed # запустить опубликова
## Участие / безопасность / changelog
- [CONTRIBUTING.md](CONTRIBUTING.md) · [CONTRIBUTING.ru.md](CONTRIBUTING.ru.md)
- [Testing](docs/testing.md) · [Тесты](docs/ru/testing.md) — наборы, Make-цели, последние результаты
- [SECURITY.md](SECURITY.md)
- [CHANGELOG.md](CHANGELOG.md)
+3 -3
View File
@@ -205,7 +205,7 @@ def _format_key_example(key: str, raw: Mapping[str, Any]) -> object:
if "default" in raw and raw["default"] is not None:
return raw["default"]
enum = raw.get("enum")
if isinstance(enum, (list, tuple)) and enum:
if isinstance(enum, list | tuple) and enum:
return enum[0]
nested_format = raw.get("format")
if isinstance(nested_format, Mapping):
@@ -226,10 +226,10 @@ def _format_key_example(key: str, raw: Mapping[str, Any]) -> object:
return 1
if typ == "integer":
minimum = raw.get("minimum")
return int(minimum) if isinstance(minimum, (int, float)) else 1
return int(minimum) if isinstance(minimum, int | float) else 1
if typ == "number":
minimum = raw.get("minimum")
return float(minimum) if isinstance(minimum, (int, float)) else 1.0
return float(minimum) if isinstance(minimum, int | float) else 1.0
pattern = raw.get("pattern")
if isinstance(pattern, str) and "second|minute|hour|day" in pattern:
return "1/second"
+1 -1
View File
@@ -131,7 +131,7 @@ def register_acme_handlers(registry: HandlerRegistry) -> None:
entry = {"plugin": plugin_id, **{k: v for k, v in item.items() if k != "data"}}
if "digest" not in entry:
material = {k: v for k, v in entry.items() if k != "digest"}
entry["digest"] = hashlib.sha1(
entry["digest"] = hashlib.sha1( # noqa: S324 - Proxmox config digests use SHA-1
json.dumps(material, sort_keys=True, separators=(",", ":")).encode()
).hexdigest()
result.append(entry)
+12 -6
View File
@@ -245,7 +245,7 @@ def register_ceph_handlers(registry: HandlerRegistry) -> None:
def _wire_ceph_pool(name: str, item: dict[str, Any], *, index: int) -> dict[str, Any]:
pool_id = item.get("pool_id", item.get("pool"))
try:
pool_num = int(pool_id)
pool_num = int(pool_id) if pool_id is not None else index
except (TypeError, ValueError):
pool_num = index
crush_raw = item.get("crush_rule", 0)
@@ -586,9 +586,8 @@ def register_ceph_handlers(registry: HandlerRegistry) -> None:
MAX(NULLIF(regexp_replace(external_id, '\\D', '', 'g'), '')::int),
-1
) + 1
FROM resources r JOIN nodes n ON n.id=r.node_id
WHERE n.name=$1 AND r.kind='ceph-osd'""",
node,
FROM resources
WHERE kind='ceph-osd'""",
)
osd_id = int(next_id or 0)
external_id = f"osd.{osd_id}"
@@ -607,8 +606,15 @@ def register_ceph_handlers(registry: HandlerRegistry) -> None:
"used_bytes": 0,
}
await database(request).pool.execute(
"""INSERT INTO resources(id, node_id, kind, external_id, state)
VALUES(gen_random_uuid(), $1, 'ceph-osd', $2, $3::jsonb)""",
"""INSERT INTO resources(id, node_id, cluster_id, kind, external_id, state)
VALUES(
gen_random_uuid(),
$1,
(SELECT cluster_id FROM nodes WHERE id=$1),
'ceph-osd',
$2,
$3::jsonb
)""",
node_id,
external_id,
json.dumps(osd_state, sort_keys=True),
+2 -2
View File
@@ -193,8 +193,8 @@ def register_cluster_extra_handlers(registry: HandlerRegistry) -> None:
now = int(time.time())
seeded = metrics.get("export_series")
if isinstance(seeded, list) and seeded:
series = [dict(item) for item in seeded if isinstance(item, dict)]
return {"data": series, "timestamp": now}
seeded_series = [dict(item) for item in seeded if isinstance(item, dict)]
return {"data": seeded_series, "timestamp": now}
# Build a PVE-shaped metric array from live inventory (scales with seed).
rows = await database(request).pool.fetch("SELECT name FROM nodes ORDER BY name")
series: list[dict[str, Any]] = []
+2 -1
View File
@@ -168,6 +168,7 @@ def build_core_handlers(settings: Settings) -> HandlerRegistry:
"machine": "x86_64",
}
return {
"status": str(row["status"]),
"uptime": int(payload.get("uptime") or 0),
"wait": float(payload.get("wait") or 0.0),
"idle": float(payload.get("idle") or 0.95),
@@ -344,7 +345,7 @@ def build_core_handlers(settings: Settings) -> HandlerRegistry:
"vmid": vmid,
"name": str(state.get("name") or f"{kind}-{external_id}"),
"status": status,
"template": bool(state.get("template") in {True, "1", 1}),
"template": bool(state.get("template") in (True, "1", 1)),
"cpu": _cpu_util(state, running=running),
"maxcpu": _maxcpu(state),
"mem": int(_num(state.get("mem"), 0)) if running else 0,
+1 -1
View File
@@ -30,7 +30,7 @@ def _ha_groups(metadata: dict[str, Any]) -> dict[str, dict[str, Any]]:
def _ha_digest(payload: dict[str, Any]) -> str:
material = {key: value for key, value in payload.items() if key != "digest"}
return hashlib.sha1(
return hashlib.sha1( # noqa: S324 - Proxmox HA digests use SHA-1
json.dumps(material, sort_keys=True, separators=(",", ":")).encode()
).hexdigest()
+1 -1
View File
@@ -70,7 +70,7 @@ def _public_lxc_config(
continue
payload[key] = value
digest_src = {key: value for key, value in payload.items() if key != "digest"}
payload["digest"] = hashlib.sha1(
payload["digest"] = hashlib.sha1( # noqa: S324 - Proxmox config digests use SHA-1
json.dumps(digest_src, sort_keys=True, separators=(",", ":")).encode()
).hexdigest()
return payload
+4 -4
View File
@@ -554,10 +554,10 @@ def register_nodes_extra_handlers(registry: HandlerRegistry) -> None:
}
)
else:
path = payload.get("path")
target_path = payload.get("path")
index = payload.get("index")
for file_idx, file_entry in enumerate(files):
if path is not None and file_entry.get("path") != path:
if target_path is not None and file_entry.get("path") != target_path:
continue
repos = [
dict(item)
@@ -584,7 +584,7 @@ def register_nodes_extra_handlers(registry: HandlerRegistry) -> None:
}
},
}
elif path is not None or file_idx == 0:
elif target_path is not None or file_idx == 0:
if repos:
repos[0] = {
**repos[0],
@@ -604,7 +604,7 @@ def register_nodes_extra_handlers(registry: HandlerRegistry) -> None:
},
}
file_entry["repositories"] = repos
if path is not None:
if target_path is not None:
break
apt["repositories"] = {
"digest": secrets.token_hex(4),
+16 -2
View File
@@ -78,12 +78,26 @@ def _public_qemu_config(config: dict[str, Any], state: dict[str, Any]) -> dict[s
agent = config.get("agent", state.get("agent", "0"))
payload["agent"] = _agent_config_string(agent)
digest_src = {key: value for key, value in payload.items() if key != "digest"}
payload["digest"] = hashlib.sha1(
payload["digest"] = hashlib.sha1( # noqa: S324 - Proxmox config digests use SHA-1
json.dumps(digest_src, sort_keys=True, separators=(",", ":")).encode()
).hexdigest()
return payload
def _cpu_utilization(vm_state: Mapping[str, Any], *, running: bool, idle: float = 0.1) -> float:
"""Status ``cpu`` is utilization; config may store model strings like ``qemu64``."""
raw = vm_state.get("cpu")
if isinstance(raw, (int, float)):
return float(raw)
if isinstance(raw, str):
try:
return float(raw)
except ValueError:
pass
return idle if running else 0.0
def _qemu_index_item(vmid: int, vm_state: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]:
status = str(vm_state.get("status", "stopped"))
running = status in {"running", "paused"}
@@ -96,7 +110,7 @@ def _qemu_index_item(vmid: int, vm_state: dict[str, Any], config: dict[str, Any]
"status": status,
"qmpstatus": status if running else "stopped",
"cpus": int(config.get("cores", config.get("cpus", 1))),
"cpu": float(vm_state.get("cpu", 0.1 if running else 0.0)),
"cpu": _cpu_utilization(vm_state, running=running),
"maxmem": maxmem,
"mem": mem,
"memhost": int(vm_state.get("memhost", mem)),
+9 -9
View File
@@ -1682,7 +1682,7 @@ def _seed_tasks_for_guests(
return tuple(tasks)
# UI / ops cluster sizes (hosts × total guests). Infra scales with the size.
# UI / ops cluster sizes (hosts x total guests). Infra scales with the size.
_CLUSTER_SIZE_SPECS: dict[str, dict[str, int]] = {
"lab": {
"nodes": 1,
@@ -1953,13 +1953,13 @@ def _sized_cluster_profile(name: str, *, overrides: dict[str, int] | None = None
def small_profile() -> SeedProfile:
"""DATA UI: 3 hosts × 50 guests."""
"""DATA UI: 3 hosts x 50 guests."""
return _sized_cluster_profile("small")
def large_profile(*, node_count: int = 10, resource_count: int = 1_000) -> SeedProfile:
"""DATA UI: 10 hosts × 1000 guests (overrides allowed for CLI stress)."""
"""DATA UI: 10 hosts x 1000 guests (overrides allowed for CLI stress)."""
if node_count < 1 or resource_count < 1:
raise ValueError("large profile counts must be positive")
@@ -1971,22 +1971,22 @@ def large_profile(*, node_count: int = 10, resource_count: int = 1_000) -> SeedP
overrides={
"nodes": node_count,
"guests": resource_count,
"osds": max(10, int(round(100 * scale))),
"ha": max(3, int(round(12 * scale))),
"tasks": max(12, int(round(50 * scale))),
"pool_members": max(12, min(resource_count, int(round(50 * scale)))),
"osds": max(10, round(100 * scale)),
"ha": max(3, round(12 * scale)),
"tasks": max(12, round(50 * scale)),
"pool_members": max(12, min(resource_count, round(50 * scale))),
},
)
def big_profile() -> SeedProfile:
"""DATA UI: 20 hosts × 2000 guests."""
"""DATA UI: 20 hosts x 2000 guests."""
return _sized_cluster_profile("big")
def medium_profile() -> SeedProfile:
"""Compatibility alias for the small (3×50) cluster."""
"""Compatibility alias for the small (3x50) cluster."""
profile = small_profile()
return SeedProfile("medium", profile.nodes, profile.resources, profile.tasks)
+2
View File
@@ -27,9 +27,11 @@ page. Russian mirrors live under [`ru/`](ru/README.md).
| [FAQ](faq.md) | Short answers |
| [Architecture](architecture.md) | Component boundaries |
| [Compatibility](compatibility.md) | Evidence model and release matrix |
| [Testing](testing.md) | Suites layout, Make targets, latest results |
Runnable cookbooks: [`examples/`](../examples/README.md).
Integration suites: [`pulumi-tests/`](../pulumi-tests/README.md).
Test map and latest pass notes: [Testing](testing.md).
## See also
+1
View File
@@ -19,6 +19,7 @@ Yes. That is a primary use case. Pin the API major, seed a profile, and point
clients at **HTTP `:8006`** (Compose) or your Ingress **HTTPS** hostname on
Kubernetes. See [Clients](clients.md). For the
Pulumi surface suite see [`pulumi-tests/`](../pulumi-tests/README.md).
Full test map and latest recorded results: [Testing](testing.md).
## Why do some OpenID / LDAP / ACME / Ceph calls “succeed” without remotes?
+2
View File
@@ -27,9 +27,11 @@
| [FAQ](faq.md) | Краткие ответы |
| [Архитектура](architecture.md) | Границы компонентов |
| [Совместимость](compatibility.md) | Модель evidence и матрица релизов |
| [Тесты](testing.md) | Карта наборов, Make-цели, последние результаты |
Исполняемые cookbook'и: [`examples/`](../../examples/README.ru.md).
Интеграционные наборы: [`pulumi-tests/`](../../pulumi-tests/README.ru.md).
Карта тестов и последние прогоны: [Тесты](testing.md).
## См. также
+1
View File
@@ -19,6 +19,7 @@ PostgreSQL, а не процессы KVM/LXC.
Да. Это один из основных сценариев. Закрепите мажор API, загрузите профиль seed и
направьте клиентов на **HTTP `:8006`** (Compose) или Ingress **HTTPS** в Kubernetes. См. [Клиенты](clients.md).
Набор Pulumi surface — [`pulumi-tests/`](../../pulumi-tests/README.ru.md).
Карта тестов и последние результаты: [Тесты](testing.md).
## Почему некоторые вызовы OpenID / LDAP / ACME / Ceph «успешны» без внешних систем?
+52
View File
@@ -0,0 +1,52 @@
**Language / Язык:** [English](../testing.md) | [Русский](testing.md)
# Тесты
Где живут наборы и как их запускать.
## Структура
| Путь | Назначение |
|---|---|
| `tests/unit/` | Офлайн unit-тесты (handlers, контракты, seed, workers, …) |
| `tests/integration/` | Тесты с PostgreSQL (миграции, lease задач) |
| `tests/compatibility/` | Surface probe, group smoke, proxmoxer HTTPS, verified-surface |
| `pulumi-tests/` | Pulumi surface (majors 69) + lifecycle `pulumi-proxmoxve`; HTML-отчёт |
Seed: `app/simulation/seed.py` (`lab` для in-process surface CI, `small` для Compose / Pulumi).
## Цели Make
```bash
make test-unit # pytest: только unit
make test-integration # pytest -m integration (нужен Postgres)
make test-surface # каждый объявленный метод majors 6–9
make test-compatibility # proxmoxer smoke (Compose --profile tls)
make ci # ruff + mypy + offline pytest + surface
make ci-all # ci + остальной integration + proxmoxer
make pulumi-tests # Pulumi surface + lifecycle; HTML-отчёт
```
GitHub Actions на push/PR в `main` запускает `make ci` и проверку Compose/Helm.
Перед крупными API-изменениями локально: `make ci-all` и `make pulumi-tests`.
Отчёт Pulumi: `pulumi-tests/pulumi/reports/report.html` — см.
[`pulumi-tests/README.ru.md`](../../pulumi-tests/README.ru.md).
## Последний локальный прогон (2026-07-18)
Полный прогон без ограничений на этом checkout:
| Gate | Результат |
|---|---|
| `make ci-all` | **PASS** (exit 0) |
| — unit (offline pytest) | 193 passed |
| — surface probe (majors 69) | PASS |
| — integration | 12 passed |
| — proxmoxer compatibility | PASS |
| `make pulumi-tests` | **PASS** (exit 0), ~28 с |
| — lifecycle (`pulumi-proxmoxve`) | PASS |
| — surface PVE 6.4-15 / 7.4-16 / 8.4.5 / 9.2.3 | PASS, `critical=0` |
| — coverage | **2324 / 2324** методов majors 69 |
Артефакты: `pulumi-tests/pulumi/reports/report.html` (также `results.json`, `junit.xml`).
+52
View File
@@ -0,0 +1,52 @@
**Language / Язык:** [English](testing.md) | [Русский](ru/testing.md)
# Testing
How the repository is tested and where each suite lives.
## Layout
| Location | Role |
|---|---|
| `tests/unit/` | Offline unit tests (handlers, contracts, seed, workers, …) |
| `tests/integration/` | PostgreSQL-backed tests (migrations, durable task leases) |
| `tests/compatibility/` | Surface probe, group smoke, proxmoxer HTTPS, verified-surface ledgers |
| `pulumi-tests/` | Pulumi surface matrix (majors 69) + `pulumi-proxmoxve` lifecycle; HTML report |
Seed used by most suites: `app/simulation/seed.py` (`lab` for in-process surface CI, `small` for Compose / Pulumi).
## Make targets
```bash
make test-unit # pytest: unit only
make test-integration # pytest -m integration (needs Postgres)
make test-surface # every declared method on majors 69
make test-compatibility # proxmoxer smoke (Compose --profile tls)
make ci # ruff + mypy + offline pytest + surface
make ci-all # ci + remaining integration + proxmoxer
make pulumi-tests # Pulumi surface + lifecycle; writes HTML report
```
GitHub Actions on push/PR to `main` runs `make ci` plus Compose/Helm validation.
Run `make ci-all` and `make pulumi-tests` locally before larger API changes.
Pulumi report (after a suite run):
`pulumi-tests/pulumi/reports/report.html` — see [`pulumi-tests/README.md`](../pulumi-tests/README.md).
## Latest local results (2026-07-18)
Full unrestricted run on this checkout:
| Gate | Result |
|---|---|
| `make ci-all` | **PASS** (exit 0) |
| — unit (offline pytest) | 193 passed |
| — surface probe (majors 69) | PASS |
| — integration | 12 passed |
| — proxmoxer compatibility | PASS |
| `make pulumi-tests` | **PASS** (exit 0), suite ~28s |
| — lifecycle (`pulumi-proxmoxve`) | PASS |
| — surface PVE 6.4-15 / 7.4-16 / 8.4.5 / 9.2.3 | PASS, `critical=0` |
| — coverage | **2324 / 2324** methods across majors 69 |
Report artifact: `pulumi-tests/pulumi/reports/report.html` (also `results.json`, `junit.xml`).
+10 -2
View File
@@ -58,7 +58,7 @@ CSRF=$(echo "$TICKET" | jq -r .data.CSRFPreventionToken)
curl -s -b /tmp/pve.ck http://localhost:8006/api2/json/version | jq .
curl -s -b /tmp/pve.ck -H "CSRFPreventionToken: $CSRF" \
-d 'vmid=100&name=demo' \
http://localhost:8006/api2/json/nodes/pve01/qemu | jq .
http://localhost:8006/api2/json/nodes/pve1/qemu | jq .
```
Open the HTML report: `pulumi-tests/pulumi/reports/report.html`.
@@ -67,4 +67,12 @@ 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).
Seed for CI/suite: `SEED_PROFILE=small` (Compose default).
Seed for CI/suite: `SEED_PROFILE=small` (Compose default). Default node name in
that profile is **`pve1`** (not `pve01`).
## Latest local results (2026-07-18)
`make pulumi-tests`**Suite PASS** (~28s): lifecycle OK; surface majors
6.4-15 / 7.4-16 / 8.4.5 / 9.2.3 with `critical=0`; coverage **2324/2324**.
Report: `pulumi/reports/report.html`. Broader CI notes:
[docs/testing.md](../docs/testing.md).
+10 -2
View File
@@ -58,7 +58,7 @@ CSRF=$(echo "$TICKET" | jq -r .data.CSRFPreventionToken)
curl -s -b /tmp/pve.ck http://localhost:8006/api2/json/version | jq .
curl -s -b /tmp/pve.ck -H "CSRFPreventionToken: $CSRF" \
-d 'vmid=100&name=demo' \
http://localhost:8006/api2/json/nodes/pve01/qemu | jq .
http://localhost:8006/api2/json/nodes/pve1/qemu | jq .
```
Отчёт: `pulumi-tests/pulumi/reports/report.html`.
@@ -67,4 +67,12 @@ Env провайдера (Compose): `PROXMOX_VE_ENDPOINT=https://tls-gateway:844
(внутренний TLS suite — `pulumi-proxmoxve` не принимает `http://`), плюс
username/password/`INSECURE`. Surface probe: `API_URL=http://simulator:8006`.
Хостовый lab URL — plain `http://localhost:8006/` (HTTPS в K8s — только Ingress).
Seed для CI/suite: `SEED_PROFILE=small` (default Compose).
Seed для CI/suite: `SEED_PROFILE=small` (default Compose). Имя ноды в этом
профиле — **`pve1`** (не `pve01`).
## Последний локальный прогон (2026-07-18)
`make pulumi-tests`**Suite PASS** (~28 с): lifecycle OK; surface majors
6.4-15 / 7.4-16 / 8.4.5 / 9.2.3 с `critical=0`; coverage **2324/2324**.
Отчёт: `pulumi/reports/report.html`. Общий CI:
[docs/ru/testing.md](../docs/ru/testing.md).
+1 -1
View File
@@ -5,7 +5,7 @@ x-test-env: &test-env
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_NODE: ${PVE_NODE:-pve1}
PVE_STORAGE: ${PVE_STORAGE:-local-lvm}
PVE_BRIDGE: ${PVE_BRIDGE:-vmbr0}
API_TIMEOUT: ${API_TIMEOUT:-120}
@@ -0,0 +1,3 @@
encryptionsalt: v1:dHE0479O4C4=:v1:qLAPiOnywZwAY0nr:lwYFJLsSb1ayad79xjxO05Tvn5EK0w==
config:
hx-lifecycle:smoke: "0"
@@ -0,0 +1,3 @@
encryptionsalt: v1:Ttb4rpyx/lw=:v1:jISbowPFxMmrEKuc:YSoO8SdSx3symHxQE0vjSEQ2Mf/AyQ==
config:
hx-lifecycle:smoke: "0"
@@ -18,7 +18,7 @@ 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")
node = os.environ.get("PVE_NODE", "pve1")
endpoint = (
os.environ.get("PROXMOX_VE_ENDPOINT") or "https://tls-gateway:8443/"
).rstrip("/") + "/"
+1 -1
View File
@@ -15,7 +15,7 @@ class Pve:
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.node = os.environ.get("PVE_NODE", "pve1")
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)
+3 -3
View File
@@ -44,7 +44,7 @@ _UPID_RE = re.compile(
)
_PATH_PARAM_EXAMPLES: dict[str, object] = {
"node": "pve01",
"node": "pve1",
"vmid": 100,
"storage": "local",
"pool": "testpool",
@@ -53,7 +53,7 @@ _PATH_PARAM_EXAMPLES: dict[str, object] = {
"realm": "pam",
"group": "admins",
"role": "Administrator",
"upid": "UPID:pve01:00000001:00000001:65000001:qmstart:100:root@pam:",
"upid": "UPID:pve1:00000001:00000001:65000001:qmstart:100:root@pam:",
"snapname": "snap1",
"volume": "local:100/vm-100-disk-0.qcow2",
"disk": "scsi0",
@@ -89,7 +89,7 @@ _EXTRA_PATH: dict[str, object] = {
"cidr": "10.0.0.0/24",
"tokenid": "automation",
"fabric_id": "fab1",
"node_id": "pve01",
"node_id": "pve1",
"url_seq": "1",
"route-map-id": "rm1",
"order": "10",
+5 -5
View File
@@ -33,7 +33,7 @@ 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"] == 100 for vm in proxmox.nodes("pve1").qemu.get())
assert any(vm["vmid"] == 103 for vm in proxmox.nodes("pve1").qemu.get())
token_api = ProxmoxAPI(
os.environ["PROXMOXER_HOST"],
@@ -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("100").config.get()["vmid"] == 100
assert readonly_api.nodes("pve1").qemu("103").config.get()["vmid"] == 103
with pytest.raises(ResourceException) as denied:
readonly_api.nodes("pve1").qemu("100").status.start.post()
readonly_api.nodes("pve1").qemu("103").status.start.post()
assert denied.value.status_code == 403
token_endpoint = proxmox.access.users("root@pam").token("ephemeral")
@@ -88,7 +88,7 @@ def test_proxmoxer_read_and_qemu_task_flow() -> None:
token_value=os.getenv("PROXMOXER_STORAGE_TOKEN_SECRET", "storage-secret"),
verify_ssl=False,
)
for vmid in ("100", "999999"):
for vmid in ("103", "999999"):
with pytest.raises(ResourceException) as hidden:
storage_api.nodes("pve1").qemu(vmid).config.get()
assert hidden.value.status_code == 403
@@ -176,7 +176,7 @@ def test_proxmoxer_read_and_qemu_task_flow() -> None:
token_value=os.getenv("PROXMOXER_OPERATOR_TOKEN_SECRET", "operator-secret"),
verify_ssl=False,
)
status_resource = operator_api.nodes("pve1").qemu("100").status
status_resource = operator_api.nodes("pve1").qemu("103").status
def run(operation: str, expected: str) -> None:
upid = status_resource(operation).post()
+20 -12
View File
@@ -86,11 +86,24 @@ async def test_backup_jobs_crud_and_shapes() -> None:
pool = BackupPool()
http = _request(pool)
listed = await registry.get("/cluster/backup", "GET")(http, {"values": {}})
list_jobs = registry.get("/cluster/backup", "GET")
create_job = registry.get("/cluster/backup", "POST")
backup_info = registry.get("/cluster/backup-info", "GET")
not_backed_up = registry.get("/cluster/backup-info/not-backed-up", "GET")
included_volumes = registry.get("/cluster/backup/{id}/included_volumes", "GET")
delete_job = registry.get("/cluster/backup/{id}", "DELETE")
assert list_jobs is not None
assert create_job is not None
assert backup_info is not None
assert not_backed_up is not None
assert included_volumes is not None
assert delete_job is not None
listed = await list_jobs(http, {"values": {}})
assert listed[0]["id"] == "backup-daily"
assert listed[0]["schedule"] == "0 2 * * *"
await registry.get("/cluster/backup", "POST")(
await create_job(
http,
{
"values": {
@@ -103,24 +116,19 @@ async def test_backup_jobs_crud_and_shapes() -> None:
)
assert "backup-weekly" in pool.metadata["backup_jobs"]
info = await registry.get("/cluster/backup-info", "GET")(http, {"values": {}})
info = await backup_info(http, {"values": {}})
assert info == [{"subdir": "not-backed-up"}]
missing = await registry.get("/cluster/backup-info/not-backed-up", "GET")(http, {"values": {}})
assert missing == [] # 100 covered by daily; after weekly both covered? weekly adds 200
missing = await not_backed_up(http, {"values": {}})
# daily covers 100, weekly covers 200 → none missing
assert missing == []
included = await registry.get("/cluster/backup/{id}/included_volumes", "GET")(
http, {"values": {"id": "backup-daily"}}
)
included = await included_volumes(http, {"values": {"id": "backup-daily"}})
assert included["children"][0]["id"] == 100
assert included["children"][0]["children"][0]["id"] == "scsi0"
await registry.get("/cluster/backup/{id}", "DELETE")(http, {"values": {"id": "backup-weekly"}})
await delete_job(http, {"values": {"id": "backup-weekly"}})
assert "backup-weekly" not in pool.metadata["backup_jobs"]
missing_after = await registry.get("/cluster/backup-info/not-backed-up", "GET")(
http, {"values": {}}
)
missing_after = await not_backed_up(http, {"values": {}})
assert missing_after[0]["vmid"] == 200
assert missing_after[0]["type"] == "lxc"
+3
View File
@@ -93,6 +93,9 @@ async def test_worker_retries_after_claim_failure() -> None:
poll_seconds=0.001,
)
running = asyncio.create_task(worker.run())
for _ in range(200):
if repository.attempts > 1:
break
await asyncio.sleep(0.01)
worker.stop()
await running