246 lines
8.8 KiB
Python
246 lines
8.8 KiB
Python
"""Reservations API unit tests."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
from nix_builder_autoscaler.api import create_app
|
|
from nix_builder_autoscaler.config import AppConfig, CapacityConfig
|
|
from nix_builder_autoscaler.metrics import MetricsRegistry
|
|
from nix_builder_autoscaler.models import SlotState
|
|
from nix_builder_autoscaler.providers.clock import FakeClock
|
|
from nix_builder_autoscaler.state_db import StateDB
|
|
|
|
|
|
def _make_client(
|
|
*,
|
|
reconcile_now: Any = None, # noqa: ANN401
|
|
) -> tuple[TestClient, StateDB, FakeClock, MetricsRegistry]:
|
|
clock = FakeClock()
|
|
db = StateDB(":memory:", clock=clock)
|
|
db.init_schema()
|
|
db.init_slots("slot", 3, "x86_64-linux", "all")
|
|
config = AppConfig(capacity=CapacityConfig(reservation_ttl_seconds=1200))
|
|
metrics = MetricsRegistry()
|
|
app = create_app(db, config, clock, metrics, reconcile_now=reconcile_now)
|
|
return TestClient(app), db, clock, metrics
|
|
|
|
|
|
def test_create_reservation_returns_200() -> None:
|
|
client, _, _, _ = _make_client()
|
|
response = client.post("/v1/reservations", json={"system": "x86_64-linux", "reason": "test"})
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["reservation_id"].startswith("resv_")
|
|
assert body["phase"] == "pending"
|
|
assert body["system"] == "x86_64-linux"
|
|
assert "created_at" in body
|
|
assert "expires_at" in body
|
|
|
|
|
|
def test_get_reservation_returns_current_phase() -> None:
|
|
client, _, _, _ = _make_client()
|
|
created = client.post("/v1/reservations", json={"system": "x86_64-linux", "reason": "test"})
|
|
reservation_id = created.json()["reservation_id"]
|
|
response = client.get(f"/v1/reservations/{reservation_id}")
|
|
assert response.status_code == 200
|
|
assert response.json()["phase"] == "pending"
|
|
|
|
|
|
def test_release_reservation_moves_to_released() -> None:
|
|
client, _, _, _ = _make_client()
|
|
created = client.post("/v1/reservations", json={"system": "x86_64-linux", "reason": "test"})
|
|
reservation_id = created.json()["reservation_id"]
|
|
response = client.post(f"/v1/reservations/{reservation_id}/release")
|
|
assert response.status_code == 200
|
|
assert response.json()["phase"] == "released"
|
|
|
|
|
|
def test_unknown_reservation_returns_404() -> None:
|
|
client, _, _, _ = _make_client()
|
|
response = client.get("/v1/reservations/resv_nonexistent")
|
|
assert response.status_code == 404
|
|
body = response.json()
|
|
assert body["error"]["code"] == "not_found"
|
|
assert "request_id" in body
|
|
|
|
|
|
def test_malformed_body_returns_422() -> None:
|
|
client, _, _, _ = _make_client()
|
|
response = client.post("/v1/reservations", json={"invalid": "data"})
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_list_reservations_returns_all() -> None:
|
|
client, _, _, _ = _make_client()
|
|
client.post("/v1/reservations", json={"system": "x86_64-linux", "reason": "a"})
|
|
client.post("/v1/reservations", json={"system": "x86_64-linux", "reason": "b"})
|
|
response = client.get("/v1/reservations")
|
|
assert response.status_code == 200
|
|
assert len(response.json()) == 2
|
|
|
|
|
|
def test_list_reservations_filters_by_phase() -> None:
|
|
client, _, _, _ = _make_client()
|
|
created = client.post("/v1/reservations", json={"system": "x86_64-linux", "reason": "test"})
|
|
reservation_id = created.json()["reservation_id"]
|
|
client.post(f"/v1/reservations/{reservation_id}/release")
|
|
response = client.get("/v1/reservations?phase=released")
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert len(body) == 1
|
|
assert body[0]["phase"] == "released"
|
|
|
|
|
|
def test_list_slots_returns_all_slots() -> None:
|
|
client, _, _, _ = _make_client()
|
|
response = client.get("/v1/slots")
|
|
assert response.status_code == 200
|
|
assert len(response.json()) == 3
|
|
|
|
|
|
def test_state_summary_returns_counts() -> None:
|
|
client, _, _, _ = _make_client()
|
|
response = client.get("/v1/state/summary")
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["slots"]["total"] == 3
|
|
assert body["slots"]["empty"] == 3
|
|
|
|
|
|
def test_effective_config_returns_capacity_and_scheduler() -> None:
|
|
client, _, _, _ = _make_client()
|
|
response = client.get("/v1/config/effective")
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["capacity"]["max_slots"] == 8
|
|
assert body["capacity"]["idle_scale_down_seconds"] == 900
|
|
assert body["scheduler"]["tick_seconds"] == 3.0
|
|
assert body["scheduler"]["reconcile_seconds"] == 15.0
|
|
|
|
|
|
def test_health_live_returns_ok() -> None:
|
|
client, _, _, _ = _make_client()
|
|
response = client.get("/health/live")
|
|
assert response.status_code == 200
|
|
assert response.json()["status"] == "ok"
|
|
|
|
|
|
def test_health_ready_returns_ok_when_no_checks() -> None:
|
|
client, _, _, _ = _make_client()
|
|
response = client.get("/health/ready")
|
|
assert response.status_code == 200
|
|
assert response.json()["status"] == "ok"
|
|
|
|
|
|
def test_health_ready_degraded_when_ready_check_fails() -> None:
|
|
clock = FakeClock()
|
|
db = StateDB(":memory:", clock=clock)
|
|
db.init_schema()
|
|
db.init_slots("slot", 3, "x86_64-linux", "all")
|
|
config = AppConfig(capacity=CapacityConfig(reservation_ttl_seconds=1200))
|
|
metrics = MetricsRegistry()
|
|
app = create_app(db, config, clock, metrics, ready_check=lambda: False)
|
|
client = TestClient(app)
|
|
response = client.get("/health/ready")
|
|
assert response.status_code == 503
|
|
assert response.json()["status"] == "degraded"
|
|
|
|
|
|
def test_metrics_returns_prometheus_text() -> None:
|
|
client, _, _, metrics = _make_client()
|
|
metrics.counter("autoscaler_test_counter", {}, 1.0)
|
|
response = client.get("/metrics")
|
|
assert response.status_code == 200
|
|
assert "text/plain" in response.headers["content-type"]
|
|
assert "autoscaler_test_counter" in response.text
|
|
|
|
|
|
def test_capacity_hint_accepted() -> None:
|
|
client, _, _, _ = _make_client()
|
|
response = client.post(
|
|
"/v1/hints/capacity",
|
|
json={
|
|
"builder": "buildbot",
|
|
"queued": 2,
|
|
"running": 4,
|
|
"system": "x86_64-linux",
|
|
"timestamp": datetime(2026, 1, 1, tzinfo=UTC).isoformat(),
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["status"] == "accepted"
|
|
|
|
|
|
def test_release_nonexistent_returns_404() -> None:
|
|
client, _, _, _ = _make_client()
|
|
response = client.post("/v1/reservations/resv_nonexistent/release")
|
|
assert response.status_code == 404
|
|
assert response.json()["error"]["code"] == "not_found"
|
|
|
|
|
|
def test_admin_drain_success() -> None:
|
|
client, db, _, _ = _make_client()
|
|
db.update_slot_state("slot001", SlotState.LAUNCHING, instance_id="i-test")
|
|
db.update_slot_state("slot001", SlotState.BOOTING)
|
|
db.update_slot_state("slot001", SlotState.BINDING, instance_ip="100.64.0.1")
|
|
db.update_slot_state("slot001", SlotState.READY)
|
|
|
|
response = client.post("/v1/admin/drain", json={"slot_id": "slot001"})
|
|
assert response.status_code == 200
|
|
assert response.json()["state"] == "draining"
|
|
slot = db.get_slot("slot001")
|
|
assert slot is not None
|
|
assert slot["state"] == SlotState.DRAINING.value
|
|
|
|
|
|
def test_admin_drain_invalid_state_returns_409() -> None:
|
|
client, _, _, _ = _make_client()
|
|
response = client.post("/v1/admin/drain", json={"slot_id": "slot001"})
|
|
assert response.status_code == 409
|
|
assert response.json()["error"]["code"] == "invalid_state"
|
|
|
|
|
|
def test_admin_unquarantine_success() -> None:
|
|
client, db, _, _ = _make_client()
|
|
db.update_slot_state("slot001", SlotState.ERROR, instance_id="i-bad")
|
|
|
|
response = client.post("/v1/admin/unquarantine", json={"slot_id": "slot001"})
|
|
assert response.status_code == 200
|
|
assert response.json()["state"] == "empty"
|
|
slot = db.get_slot("slot001")
|
|
assert slot is not None
|
|
assert slot["state"] == SlotState.EMPTY.value
|
|
assert slot["instance_id"] is None
|
|
|
|
|
|
def test_admin_unquarantine_invalid_state_returns_409() -> None:
|
|
client, _, _, _ = _make_client()
|
|
response = client.post("/v1/admin/unquarantine", json={"slot_id": "slot001"})
|
|
assert response.status_code == 409
|
|
assert response.json()["error"]["code"] == "invalid_state"
|
|
|
|
|
|
def test_admin_reconcile_now_not_configured_returns_503() -> None:
|
|
client, _, _, _ = _make_client()
|
|
response = client.post("/v1/admin/reconcile-now")
|
|
assert response.status_code == 503
|
|
assert response.json()["error"]["code"] == "not_configured"
|
|
|
|
|
|
def test_admin_reconcile_now_success() -> None:
|
|
called = {"value": False}
|
|
|
|
def _reconcile_now() -> dict[str, object]:
|
|
called["value"] = True
|
|
return {"triggered": True}
|
|
|
|
client, _, _, _ = _make_client(reconcile_now=_reconcile_now)
|
|
response = client.post("/v1/admin/reconcile-now")
|
|
assert response.status_code == 200
|
|
assert response.json()["status"] == "accepted"
|
|
assert response.json()["triggered"] is True
|
|
assert called["value"] is True
|