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 to `main`. Run `make ci-all`, `make helm-lint`, and `make pulumi-tests` locally
before larger API or client-facing changes. 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 ## Project rules worth remembering
1. Mutations must **persist to PostgreSQL** (tables and/or jsonb metadata). 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/клиентскими изменениями локально прогоняйте Compose/Helm. Перед крупными API/клиентскими изменениями локально прогоняйте
`make ci-all`, `make helm-lint` и `make pulumi-tests`. `make ci-all`, `make helm-lint` и `make pulumi-tests`.
Карта наборов, Make-цели и последние записанные локальные результаты:
[docs/ru/testing.md](docs/ru/testing.md).
## Важные правила проекта ## Важные правила проекта
1. Мутации должны **persist в PostgreSQL** (таблицы и/или jsonb metadata). 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 / security / changelog
- [CONTRIBUTING.md](CONTRIBUTING.md) · [CONTRIBUTING.ru.md](CONTRIBUTING.ru.md) - [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) - [SECURITY.md](SECURITY.md)
- [CHANGELOG.md](CHANGELOG.md) - [CHANGELOG.md](CHANGELOG.md)
+1
View File
@@ -194,6 +194,7 @@ make release-up && make release-seed # запустить опубликова
## Участие / безопасность / changelog ## Участие / безопасность / changelog
- [CONTRIBUTING.md](CONTRIBUTING.md) · [CONTRIBUTING.ru.md](CONTRIBUTING.ru.md) - [CONTRIBUTING.md](CONTRIBUTING.md) · [CONTRIBUTING.ru.md](CONTRIBUTING.ru.md)
- [Testing](docs/testing.md) · [Тесты](docs/ru/testing.md) — наборы, Make-цели, последние результаты
- [SECURITY.md](SECURITY.md) - [SECURITY.md](SECURITY.md)
- [CHANGELOG.md](CHANGELOG.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: if "default" in raw and raw["default"] is not None:
return raw["default"] return raw["default"]
enum = raw.get("enum") enum = raw.get("enum")
if isinstance(enum, (list, tuple)) and enum: if isinstance(enum, list | tuple) and enum:
return enum[0] return enum[0]
nested_format = raw.get("format") nested_format = raw.get("format")
if isinstance(nested_format, Mapping): if isinstance(nested_format, Mapping):
@@ -226,10 +226,10 @@ def _format_key_example(key: str, raw: Mapping[str, Any]) -> object:
return 1 return 1
if typ == "integer": if typ == "integer":
minimum = raw.get("minimum") 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": if typ == "number":
minimum = raw.get("minimum") 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") pattern = raw.get("pattern")
if isinstance(pattern, str) and "second|minute|hour|day" in pattern: if isinstance(pattern, str) and "second|minute|hour|day" in pattern:
return "1/second" 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"}} entry = {"plugin": plugin_id, **{k: v for k, v in item.items() if k != "data"}}
if "digest" not in entry: if "digest" not in entry:
material = {k: v for k, v in entry.items() if k != "digest"} 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() json.dumps(material, sort_keys=True, separators=(",", ":")).encode()
).hexdigest() ).hexdigest()
result.append(entry) 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]: def _wire_ceph_pool(name: str, item: dict[str, Any], *, index: int) -> dict[str, Any]:
pool_id = item.get("pool_id", item.get("pool")) pool_id = item.get("pool_id", item.get("pool"))
try: try:
pool_num = int(pool_id) pool_num = int(pool_id) if pool_id is not None else index
except (TypeError, ValueError): except (TypeError, ValueError):
pool_num = index pool_num = index
crush_raw = item.get("crush_rule", 0) 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), MAX(NULLIF(regexp_replace(external_id, '\\D', '', 'g'), '')::int),
-1 -1
) + 1 ) + 1
FROM resources r JOIN nodes n ON n.id=r.node_id FROM resources
WHERE n.name=$1 AND r.kind='ceph-osd'""", WHERE kind='ceph-osd'""",
node,
) )
osd_id = int(next_id or 0) osd_id = int(next_id or 0)
external_id = f"osd.{osd_id}" external_id = f"osd.{osd_id}"
@@ -607,8 +606,15 @@ def register_ceph_handlers(registry: HandlerRegistry) -> None:
"used_bytes": 0, "used_bytes": 0,
} }
await database(request).pool.execute( await database(request).pool.execute(
"""INSERT INTO resources(id, node_id, kind, external_id, state) """INSERT INTO resources(id, node_id, cluster_id, kind, external_id, state)
VALUES(gen_random_uuid(), $1, 'ceph-osd', $2, $3::jsonb)""", VALUES(
gen_random_uuid(),
$1,
(SELECT cluster_id FROM nodes WHERE id=$1),
'ceph-osd',
$2,
$3::jsonb
)""",
node_id, node_id,
external_id, external_id,
json.dumps(osd_state, sort_keys=True), 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()) now = int(time.time())
seeded = metrics.get("export_series") seeded = metrics.get("export_series")
if isinstance(seeded, list) and seeded: if isinstance(seeded, list) and seeded:
series = [dict(item) for item in seeded if isinstance(item, dict)] seeded_series = [dict(item) for item in seeded if isinstance(item, dict)]
return {"data": series, "timestamp": now} return {"data": seeded_series, "timestamp": now}
# Build a PVE-shaped metric array from live inventory (scales with seed). # 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") rows = await database(request).pool.fetch("SELECT name FROM nodes ORDER BY name")
series: list[dict[str, Any]] = [] series: list[dict[str, Any]] = []
+2 -1
View File
@@ -168,6 +168,7 @@ def build_core_handlers(settings: Settings) -> HandlerRegistry:
"machine": "x86_64", "machine": "x86_64",
} }
return { return {
"status": str(row["status"]),
"uptime": int(payload.get("uptime") or 0), "uptime": int(payload.get("uptime") or 0),
"wait": float(payload.get("wait") or 0.0), "wait": float(payload.get("wait") or 0.0),
"idle": float(payload.get("idle") or 0.95), "idle": float(payload.get("idle") or 0.95),
@@ -344,7 +345,7 @@ def build_core_handlers(settings: Settings) -> HandlerRegistry:
"vmid": vmid, "vmid": vmid,
"name": str(state.get("name") or f"{kind}-{external_id}"), "name": str(state.get("name") or f"{kind}-{external_id}"),
"status": status, "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), "cpu": _cpu_util(state, running=running),
"maxcpu": _maxcpu(state), "maxcpu": _maxcpu(state),
"mem": int(_num(state.get("mem"), 0)) if running else 0, "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: def _ha_digest(payload: dict[str, Any]) -> str:
material = {key: value for key, value in payload.items() if key != "digest"} 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() json.dumps(material, sort_keys=True, separators=(",", ":")).encode()
).hexdigest() ).hexdigest()
+1 -1
View File
@@ -70,7 +70,7 @@ def _public_lxc_config(
continue continue
payload[key] = value payload[key] = value
digest_src = {key: value for key, value in payload.items() if key != "digest"} 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() json.dumps(digest_src, sort_keys=True, separators=(",", ":")).encode()
).hexdigest() ).hexdigest()
return payload return payload
+4 -4
View File
@@ -554,10 +554,10 @@ def register_nodes_extra_handlers(registry: HandlerRegistry) -> None:
} }
) )
else: else:
path = payload.get("path") target_path = payload.get("path")
index = payload.get("index") index = payload.get("index")
for file_idx, file_entry in enumerate(files): 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 continue
repos = [ repos = [
dict(item) 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: if repos:
repos[0] = { repos[0] = {
**repos[0], **repos[0],
@@ -604,7 +604,7 @@ def register_nodes_extra_handlers(registry: HandlerRegistry) -> None:
}, },
} }
file_entry["repositories"] = repos file_entry["repositories"] = repos
if path is not None: if target_path is not None:
break break
apt["repositories"] = { apt["repositories"] = {
"digest": secrets.token_hex(4), "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")) agent = config.get("agent", state.get("agent", "0"))
payload["agent"] = _agent_config_string(agent) payload["agent"] = _agent_config_string(agent)
digest_src = {key: value for key, value in payload.items() if key != "digest"} 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() json.dumps(digest_src, sort_keys=True, separators=(",", ":")).encode()
).hexdigest() ).hexdigest()
return payload 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]: 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")) status = str(vm_state.get("status", "stopped"))
running = status in {"running", "paused"} 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, "status": status,
"qmpstatus": status if running else "stopped", "qmpstatus": status if running else "stopped",
"cpus": int(config.get("cores", config.get("cpus", 1))), "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, "maxmem": maxmem,
"mem": mem, "mem": mem,
"memhost": int(vm_state.get("memhost", mem)), "memhost": int(vm_state.get("memhost", mem)),
+9 -9
View File
@@ -1682,7 +1682,7 @@ def _seed_tasks_for_guests(
return tuple(tasks) 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]] = { _CLUSTER_SIZE_SPECS: dict[str, dict[str, int]] = {
"lab": { "lab": {
"nodes": 1, "nodes": 1,
@@ -1953,13 +1953,13 @@ def _sized_cluster_profile(name: str, *, overrides: dict[str, int] | None = None
def small_profile() -> SeedProfile: def small_profile() -> SeedProfile:
"""DATA UI: 3 hosts × 50 guests.""" """DATA UI: 3 hosts x 50 guests."""
return _sized_cluster_profile("small") return _sized_cluster_profile("small")
def large_profile(*, node_count: int = 10, resource_count: int = 1_000) -> SeedProfile: 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: if node_count < 1 or resource_count < 1:
raise ValueError("large profile counts must be positive") 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={ overrides={
"nodes": node_count, "nodes": node_count,
"guests": resource_count, "guests": resource_count,
"osds": max(10, int(round(100 * scale))), "osds": max(10, round(100 * scale)),
"ha": max(3, int(round(12 * scale))), "ha": max(3, round(12 * scale)),
"tasks": max(12, int(round(50 * scale))), "tasks": max(12, round(50 * scale)),
"pool_members": max(12, min(resource_count, int(round(50 * scale)))), "pool_members": max(12, min(resource_count, round(50 * scale))),
}, },
) )
def big_profile() -> SeedProfile: def big_profile() -> SeedProfile:
"""DATA UI: 20 hosts × 2000 guests.""" """DATA UI: 20 hosts x 2000 guests."""
return _sized_cluster_profile("big") return _sized_cluster_profile("big")
def medium_profile() -> SeedProfile: def medium_profile() -> SeedProfile:
"""Compatibility alias for the small (3×50) cluster.""" """Compatibility alias for the small (3x50) cluster."""
profile = small_profile() profile = small_profile()
return SeedProfile("medium", profile.nodes, profile.resources, profile.tasks) 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 | | [FAQ](faq.md) | Short answers |
| [Architecture](architecture.md) | Component boundaries | | [Architecture](architecture.md) | Component boundaries |
| [Compatibility](compatibility.md) | Evidence model and release matrix | | [Compatibility](compatibility.md) | Evidence model and release matrix |
| [Testing](testing.md) | Suites layout, Make targets, latest results |
Runnable cookbooks: [`examples/`](../examples/README.md). Runnable cookbooks: [`examples/`](../examples/README.md).
Integration suites: [`pulumi-tests/`](../pulumi-tests/README.md). Integration suites: [`pulumi-tests/`](../pulumi-tests/README.md).
Test map and latest pass notes: [Testing](testing.md).
## See also ## 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 clients at **HTTP `:8006`** (Compose) or your Ingress **HTTPS** hostname on
Kubernetes. See [Clients](clients.md). For the Kubernetes. See [Clients](clients.md). For the
Pulumi surface suite see [`pulumi-tests/`](../pulumi-tests/README.md). 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? ## Why do some OpenID / LDAP / ACME / Ceph calls “succeed” without remotes?
+2
View File
@@ -27,9 +27,11 @@
| [FAQ](faq.md) | Краткие ответы | | [FAQ](faq.md) | Краткие ответы |
| [Архитектура](architecture.md) | Границы компонентов | | [Архитектура](architecture.md) | Границы компонентов |
| [Совместимость](compatibility.md) | Модель evidence и матрица релизов | | [Совместимость](compatibility.md) | Модель evidence и матрица релизов |
| [Тесты](testing.md) | Карта наборов, Make-цели, последние результаты |
Исполняемые cookbook'и: [`examples/`](../../examples/README.ru.md). Исполняемые cookbook'и: [`examples/`](../../examples/README.ru.md).
Интеграционные наборы: [`pulumi-tests/`](../../pulumi-tests/README.ru.md). Интеграционные наборы: [`pulumi-tests/`](../../pulumi-tests/README.ru.md).
Карта тестов и последние прогоны: [Тесты](testing.md).
## См. также ## См. также
+1
View File
@@ -19,6 +19,7 @@ PostgreSQL, а не процессы KVM/LXC.
Да. Это один из основных сценариев. Закрепите мажор API, загрузите профиль seed и Да. Это один из основных сценариев. Закрепите мажор API, загрузите профиль seed и
направьте клиентов на **HTTP `:8006`** (Compose) или Ingress **HTTPS** в Kubernetes. См. [Клиенты](clients.md). направьте клиентов на **HTTP `:8006`** (Compose) или Ingress **HTTPS** в Kubernetes. См. [Клиенты](clients.md).
Набор Pulumi surface — [`pulumi-tests/`](../../pulumi-tests/README.ru.md). Набор Pulumi surface — [`pulumi-tests/`](../../pulumi-tests/README.ru.md).
Карта тестов и последние результаты: [Тесты](testing.md).
## Почему некоторые вызовы OpenID / LDAP / ACME / Ceph «успешны» без внешних систем? ## Почему некоторые вызовы 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 http://localhost:8006/api2/json/version | jq .
curl -s -b /tmp/pve.ck -H "CSRFPreventionToken: $CSRF" \ curl -s -b /tmp/pve.ck -H "CSRFPreventionToken: $CSRF" \
-d 'vmid=100&name=demo' \ -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`. 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 (internal suite TLS — `pulumi-proxmoxve` rejects `http://`), plus username/password
/`INSECURE`. Surface probe uses `API_URL=http://simulator:8006`. Host lab URL /`INSECURE`. Surface probe uses `API_URL=http://simulator:8006`. Host lab URL
remains plain `http://localhost:8006/` (Kubernetes HTTPS is Ingress-only). 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 http://localhost:8006/api2/json/version | jq .
curl -s -b /tmp/pve.ck -H "CSRFPreventionToken: $CSRF" \ curl -s -b /tmp/pve.ck -H "CSRFPreventionToken: $CSRF" \
-d 'vmid=100&name=demo' \ -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`. Отчёт: `pulumi-tests/pulumi/reports/report.html`.
@@ -67,4 +67,12 @@ Env провайдера (Compose): `PROXMOX_VE_ENDPOINT=https://tls-gateway:844
(внутренний TLS suite — `pulumi-proxmoxve` не принимает `http://`), плюс (внутренний TLS suite — `pulumi-proxmoxve` не принимает `http://`), плюс
username/password/`INSECURE`. Surface probe: `API_URL=http://simulator:8006`. username/password/`INSECURE`. Surface probe: `API_URL=http://simulator:8006`.
Хостовый lab URL — plain `http://localhost:8006/` (HTTPS в K8s — только Ingress). Хостовый 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_USER: ${API_USER:-root@pam}
API_PASSWORD: ${API_PASSWORD:-secret} API_PASSWORD: ${API_PASSWORD:-secret}
API_TOKEN: ${API_TOKEN:-root@pam!automation=automation-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_STORAGE: ${PVE_STORAGE:-local-lvm}
PVE_BRIDGE: ${PVE_BRIDGE:-vmbr0} PVE_BRIDGE: ${PVE_BRIDGE:-vmbr0}
API_TIMEOUT: ${API_TIMEOUT:-120} 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() cfg = pulumi.Config()
smoke = (cfg.get("smoke") or os.environ.get("SMOKE_ONLY") or "0") == "1" 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 = ( endpoint = (
os.environ.get("PROXMOX_VE_ENDPOINT") or "https://tls-gateway:8443/" os.environ.get("PROXMOX_VE_ENDPOINT") or "https://tls-gateway:8443/"
).rstrip("/") + "/" ).rstrip("/") + "/"
+1 -1
View File
@@ -15,7 +15,7 @@ class Pve:
root = os.environ.get("API_URL", "http://simulator:8006").rstrip("/") root = os.environ.get("API_URL", "http://simulator:8006").rstrip("/")
self.root = root self.root = root
self.base = root + "/api2/json" 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.storage = os.environ.get("PVE_STORAGE", "local-lvm")
self.bridge = os.environ.get("PVE_BRIDGE", "vmbr0") self.bridge = os.environ.get("PVE_BRIDGE", "vmbr0")
self._c = httpx.Client(base_url=self.base, timeout=120.0) 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] = { _PATH_PARAM_EXAMPLES: dict[str, object] = {
"node": "pve01", "node": "pve1",
"vmid": 100, "vmid": 100,
"storage": "local", "storage": "local",
"pool": "testpool", "pool": "testpool",
@@ -53,7 +53,7 @@ _PATH_PARAM_EXAMPLES: dict[str, object] = {
"realm": "pam", "realm": "pam",
"group": "admins", "group": "admins",
"role": "Administrator", "role": "Administrator",
"upid": "UPID:pve01:00000001:00000001:65000001:qmstart:100:root@pam:", "upid": "UPID:pve1:00000001:00000001:65000001:qmstart:100:root@pam:",
"snapname": "snap1", "snapname": "snap1",
"volume": "local:100/vm-100-disk-0.qcow2", "volume": "local:100/vm-100-disk-0.qcow2",
"disk": "scsi0", "disk": "scsi0",
@@ -89,7 +89,7 @@ _EXTRA_PATH: dict[str, object] = {
"cidr": "10.0.0.0/24", "cidr": "10.0.0.0/24",
"tokenid": "automation", "tokenid": "automation",
"fabric_id": "fab1", "fabric_id": "fab1",
"node_id": "pve01", "node_id": "pve1",
"url_seq": "1", "url_seq": "1",
"route-map-id": "rm1", "route-map-id": "rm1",
"order": "10", "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 proxmox.version.get()["version"] == "9.2.3"
assert any(node["node"] == "pve1" for node in proxmox.nodes.get()) 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( token_api = ProxmoxAPI(
os.environ["PROXMOXER_HOST"], 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.get()
assert readonly_api.nodes("pve1").status.get()["status"] == "online" 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: 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 assert denied.value.status_code == 403
token_endpoint = proxmox.access.users("root@pam").token("ephemeral") 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"), token_value=os.getenv("PROXMOXER_STORAGE_TOKEN_SECRET", "storage-secret"),
verify_ssl=False, verify_ssl=False,
) )
for vmid in ("100", "999999"): for vmid in ("103", "999999"):
with pytest.raises(ResourceException) as hidden: with pytest.raises(ResourceException) as hidden:
storage_api.nodes("pve1").qemu(vmid).config.get() storage_api.nodes("pve1").qemu(vmid).config.get()
assert hidden.value.status_code == 403 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"), token_value=os.getenv("PROXMOXER_OPERATOR_TOKEN_SECRET", "operator-secret"),
verify_ssl=False, 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: def run(operation: str, expected: str) -> None:
upid = status_resource(operation).post() upid = status_resource(operation).post()
+20 -12
View File
@@ -86,11 +86,24 @@ async def test_backup_jobs_crud_and_shapes() -> None:
pool = BackupPool() pool = BackupPool()
http = _request(pool) 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]["id"] == "backup-daily"
assert listed[0]["schedule"] == "0 2 * * *" assert listed[0]["schedule"] == "0 2 * * *"
await registry.get("/cluster/backup", "POST")( await create_job(
http, http,
{ {
"values": { "values": {
@@ -103,24 +116,19 @@ async def test_backup_jobs_crud_and_shapes() -> None:
) )
assert "backup-weekly" in pool.metadata["backup_jobs"] 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"}] assert info == [{"subdir": "not-backed-up"}]
missing = await registry.get("/cluster/backup-info/not-backed-up", "GET")(http, {"values": {}}) missing = await not_backed_up(http, {"values": {}})
assert missing == [] # 100 covered by daily; after weekly both covered? weekly adds 200
# daily covers 100, weekly covers 200 → none missing # daily covers 100, weekly covers 200 → none missing
assert missing == [] assert missing == []
included = await registry.get("/cluster/backup/{id}/included_volumes", "GET")( included = await included_volumes(http, {"values": {"id": "backup-daily"}})
http, {"values": {"id": "backup-daily"}}
)
assert included["children"][0]["id"] == 100 assert included["children"][0]["id"] == 100
assert included["children"][0]["children"][0]["id"] == "scsi0" 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"] assert "backup-weekly" not in pool.metadata["backup_jobs"]
missing_after = await registry.get("/cluster/backup-info/not-backed-up", "GET")( missing_after = await not_backed_up(http, {"values": {}})
http, {"values": {}}
)
assert missing_after[0]["vmid"] == 200 assert missing_after[0]["vmid"] == 200
assert missing_after[0]["type"] == "lxc" assert missing_after[0]["type"] == "lxc"
+4 -1
View File
@@ -93,7 +93,10 @@ async def test_worker_retries_after_claim_failure() -> None:
poll_seconds=0.001, poll_seconds=0.001,
) )
running = asyncio.create_task(worker.run()) running = asyncio.create_task(worker.run())
await asyncio.sleep(0.01) for _ in range(200):
if repository.attempts > 1:
break
await asyncio.sleep(0.01)
worker.stop() worker.stop()
await running await running