# 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_.". 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 primary IPs: interface -> assigned addresses -> primary_ip4/6 eth0 = api.ensure( "virtualization/interfaces", {"virtual_machine_id": vm01["id"], "name": "eth0"}, {"virtual_machine": vm01["id"], "name": "eth0"}, ) vm01_ip4 = 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"], }, ) vm01_ip6 = api.ensure( "ipam/ip-addresses", {"address": "2001:db8::20/64"}, { "address": "2001:db8::20/64", "dns_name": "vm01-v6.example.com", "assigned_object_type": "virtualization.vminterface", "assigned_object_id": eth0["id"], }, ) api.patch( "virtualization/virtual-machines", vm01["id"], {"primary_ip4": vm01_ip4["id"], "primary_ip6": vm01_ip6["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": "2001:db8::10/64", "dns_name": "v6.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 .""" if len(sys.argv) != 4: print("usage: seed.py ", 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())