You've already forked ansible-netbox-inventory
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.
This commit is contained in:
279
e2e/seed.py
Normal file
279
e2e/seed.py
Normal file
@@ -0,0 +1,279 @@
|
||||
# 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())
|
||||
Reference in New Issue
Block a user