Files
ansible-netbox-inventory/e2e/seed.py
Bryan Joshua Pedini c3d3165a54 test: end-to-end scenarios against a live netbox
seed.py populates the stack through the API: a read-only user with a
self-provisioned token, tenants, sites, clusters, a VRF, virtual
machines (one with a primary IP) and standalone addresses; the tests
run the real inventory script against every source/filter combination,
every error path and the ansible --list/--host protocol.
2026-08-25 13:47:18 +02:00

280 lines
9.7 KiB
Python

# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
"""
Seed a throwaway NetBox instance with the end-to-end test dataset.
Everything goes through the REST API with the superuser token: a read-only
user with its own token, two tenants, two sites, two clusters, a VRF, three
virtual machines (one with a primary IP) and a handful of IP addresses. All
operations are get-or-create so seeding is idempotent.
"""
import sys
import time
from typing import Any
import requests
class SeedError(Exception):
"""Raised when the NetBox instance cannot be seeded."""
class AdminApi:
"""Minimal admin client for creating objects through the NetBox API."""
def __init__(self, url: str, token: str, timeout: int = 15):
self._url = url.rstrip("/")
self._timeout = timeout
self._session = requests.Session()
scheme = "Bearer" if token.startswith("nbt_") else "Token"
self._session.headers.update(
{"Authorization": f"{scheme} {token}", "Accept": "application/json"}
)
def _check(self, response: requests.Response) -> Any:
if response.status_code not in (200, 201, 204):
raise SeedError(
f"{response.request.method} {response.url}: HTTP {response.status_code}: {response.text[:300]}"
)
return response.json() if response.status_code != 204 else None
def get(self, path: str, params: dict[str, Any] | None = None) -> list[dict[str, Any]]:
response = self._session.get(
f"{self._url}/api/{path}/", params=params, timeout=self._timeout
)
return self._check(response)["results"]
def post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
response = self._session.post(
f"{self._url}/api/{path}/", json=payload, timeout=self._timeout
)
return self._check(response)
def patch(self, path: str, object_id: int, payload: dict[str, Any]) -> dict[str, Any]:
response = self._session.patch(
f"{self._url}/api/{path}/{object_id}/", json=payload, timeout=self._timeout
)
return self._check(response)
def delete(self, path: str, object_id: int) -> None:
response = self._session.delete(
f"{self._url}/api/{path}/{object_id}/", timeout=self._timeout
)
self._check(response)
def ensure(self, path: str, lookup: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
"""Return the object matching lookup, creating it from payload if missing."""
matches = self.get(path, params=lookup)
if matches:
return matches[0]
return self.post(path, payload)
def wait_for_api(url: str, timeout: int = 300) -> None:
"""
Poll /api/status/ until NetBox answers or the timeout expires.
A 403 counts as up: it means the application is serving requests and only
authentication is missing.
"""
deadline = time.monotonic() + timeout
last_error = "no attempt made"
while time.monotonic() < deadline:
try:
response = requests.get(f"{url.rstrip('/')}/api/status/", timeout=5)
if response.status_code in (200, 403):
return
last_error = f"HTTP {response.status_code}"
except requests.exceptions.RequestException as exc:
last_error = str(exc)
time.sleep(3)
raise SeedError(f"NetBox at {url} did not come up within {timeout}s: {last_error}")
def provision_token(url: str, username: str, password: str) -> str:
"""Create a fresh API token from user credentials and return its key."""
try:
response = requests.post(
f"{url.rstrip('/')}/api/users/tokens/provision/",
json={"username": username, "password": password},
timeout=15,
)
except requests.exceptions.RequestException as exc:
raise SeedError(f"Token provisioning failed: {exc}") from exc
if response.status_code != 201:
raise SeedError(
f"Token provisioning for {username} failed: HTTP {response.status_code}: {response.text[:300]}"
)
return _token_secret(response.json())
def _token_secret(data: dict[str, Any]) -> str:
"""
Return the usable credential of a freshly created token.
NetBox 4.5 (token v2) shows the secret once in "token" and keeps only the
public identifier in "key"; the full credential is "nbt_<key>.<secret>".
Older releases expose the whole secret as "key" directly.
"""
if data.get("token"):
return f"nbt_{data['key']}.{data['token']}"
return data["key"]
def seed(url: str, admin_token: str) -> dict[str, str]:
"""Create the whole e2e dataset and return the read-only user's token."""
api = AdminApi(url, admin_token)
customer1 = api.ensure(
"tenancy/tenants", {"name": "customer1"}, {"name": "customer1", "slug": "customer1"}
)
customer2 = api.ensure(
"tenancy/tenants", {"name": "customer2"}, {"name": "customer2", "slug": "customer2"}
)
milan = api.ensure("dcim/sites", {"name": "milan"}, {"name": "milan", "slug": "milan"})
turin = api.ensure("dcim/sites", {"name": "turin"}, {"name": "turin", "slug": "turin"})
cluster_type = api.ensure(
"virtualization/cluster-types", {"name": "proxmox"}, {"name": "proxmox", "slug": "proxmox"}
)
cluster1 = api.ensure(
"virtualization/clusters",
{"name": "cluster1"},
{
"name": "cluster1",
"type": cluster_type["id"],
"scope_type": "dcim.site",
"scope_id": milan["id"],
},
)
cluster2 = api.ensure(
"virtualization/clusters",
{"name": "cluster2"},
{
"name": "cluster2",
"type": cluster_type["id"],
"scope_type": "dcim.site",
"scope_id": turin["id"],
},
)
internal = api.ensure("ipam/vrfs", {"name": "internal"}, {"name": "internal", "rd": "65000:1"})
vm01 = api.ensure(
"virtualization/virtual-machines",
{"name": "vm01"},
{
"name": "vm01",
"status": "active",
"cluster": cluster1["id"],
"site": milan["id"],
"tenant": customer1["id"],
},
)
api.ensure(
"virtualization/virtual-machines",
{"name": "vm02"},
{
"name": "vm02",
"status": "active",
"cluster": cluster1["id"],
"site": milan["id"],
"tenant": customer2["id"],
},
)
api.ensure(
"virtualization/virtual-machines",
{"name": "vm03.example.com"},
{
"name": "vm03.example.com",
"status": "active",
"cluster": cluster2["id"],
"site": turin["id"],
},
)
# give vm01 a primary IP: interface -> assigned address -> primary_ip4
eth0 = api.ensure(
"virtualization/interfaces",
{"virtual_machine_id": vm01["id"], "name": "eth0"},
{"virtual_machine": vm01["id"], "name": "eth0"},
)
vm01_ip = api.ensure(
"ipam/ip-addresses",
{"address": "192.0.2.20/24"},
{
"address": "192.0.2.20/24",
"dns_name": "vm01.example.com",
"assigned_object_type": "virtualization.vminterface",
"assigned_object_id": eth0["id"],
},
)
api.patch("virtualization/virtual-machines", vm01["id"], {"primary_ip4": vm01_ip["id"]})
standalone_addresses = [
{"address": "192.0.2.10/24", "dns_name": "web.example.com", "tenant": customer1["id"]},
{"address": "192.0.2.11/24", "vrf": internal["id"]},
{"address": "192.0.2.12/24", "dns_name": "web.example.com"},
{
"address": "192.0.2.13/24",
"dns_name": "db.other.org",
"tenant": customer2["id"],
"vrf": internal["id"],
},
]
for payload in standalone_addresses:
api.ensure("ipam/ip-addresses", {"address": payload["address"]}, payload)
# dedicated read-only user for the inventory, with a fresh token every run
# (token secrets are only revealed on self-provisioning, never to admins)
password = "Ansible-e2e-Passw0rd!"
user = api.ensure(
"users/users",
{"username": "ansible-inventory"},
{"username": "ansible-inventory", "password": password},
)
api.ensure(
"users/permissions",
{"name": "ansible-inventory read"},
{
"name": "ansible-inventory read",
"enabled": True,
"actions": ["view"],
"object_types": [
"tenancy.tenant",
"dcim.site",
"ipam.ipaddress",
"ipam.vrf",
"virtualization.cluster",
"virtualization.virtualmachine",
],
"users": [user["id"]],
},
)
for token in api.get("users/tokens", params={"user_id": user["id"]}):
api.delete("users/tokens", token["id"])
return {
"url": url,
"token": provision_token(url, user["username"], password),
"user": user["username"],
}
def main() -> int:
"""Seed from the command line: seed.py <url> <admin-user> <admin-password>."""
if len(sys.argv) != 4:
print("usage: seed.py <netbox-url> <admin-user> <admin-password>", file=sys.stderr)
return 2
try:
wait_for_api(sys.argv[1])
admin_token = provision_token(sys.argv[1], sys.argv[2], sys.argv[3])
result = seed(sys.argv[1], admin_token)
except SeedError as exc:
print(f"seed: {exc}", file=sys.stderr)
return 1
print(f"seeded, inventory token for {result['user']}: {result['token']}")
return 0
if __name__ == "__main__":
sys.exit(main())