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:
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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]] = []
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
## См. также
|
||||
|
||||
|
||||
@@ -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 «успешны» без внешних систем?
|
||||
|
||||
|
||||
@@ -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 6–9) + 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 6–9) | 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 6–9 |
|
||||
|
||||
Артефакты: `pulumi-tests/pulumi/reports/report.html` (также `results.json`, `junit.xml`).
|
||||
@@ -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 6–9) + `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 6–9
|
||||
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 6–9) | 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 6–9 |
|
||||
|
||||
Report artifact: `pulumi-tests/pulumi/reports/report.html` (also `results.json`, `junit.xml`).
|
||||
+10
-2
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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("/") + "/"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user