You've already forked ansible-netbox-inventory
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.
280 lines
9.7 KiB
Python
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())
|