Skip to content

Commit 483abce

Browse files
committed
Close #149: Updated choice_set to correct format
1 parent 9ad9fb0 commit 483abce

7 files changed

Lines changed: 353 additions & 38 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ path = "src/netbox_initializers/version.py"
3131
dev = ["ruff==0.16.2"]
3232

3333
[tool.ruff]
34-
line-length = 100
35-
target-version = "py312"
34+
line-length = 120
35+
target-version = "py314"
3636

3737
[tool.ruff.lint]
3838
extend-select = ["I", "PL", "W191", "W291", "W292", "W293"]

src/netbox_initializers/initializers/custom_fields.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,22 +123,27 @@ def load_data(self):
123123
continue
124124
custom_field.validation_maximum = cf_details["validation_maximum"]
125125

126-
# choices should only be applied when type is select, multiselect
127-
if choices := cf_details.get("choices"):
126+
# choice_set should only be applied when type is select, multiselect
127+
if choice_set_choices := cf_details.get("choice_set"):
128128
if cf_details.get("type") not in (
129129
"select",
130130
"multiselect",
131131
):
132132
print(
133-
f"⚠️ Unable to create Custom Field '{cf_name}': choices is supported only "
133+
f"⚠️ Unable to create Custom Field '{cf_name}': choice_set is supported only "
134134
+ "for select and multiselect types"
135135
)
136136
custom_field.delete()
137137
continue
138138
choice_set, _ = CustomFieldChoiceSet.objects.get_or_create(
139139
name=f"{cf_name}_choices"
140140
)
141-
choice_set.extra_choices = choices
141+
# NetBox stores choices as [value, label] pairs. Allow the YAML to
142+
# provide either plain values or explicit [value, label] pairs.
143+
choice_set.extra_choices = [
144+
choice if isinstance(choice, (list, tuple)) else [choice, choice]
145+
for choice in choice_set_choices
146+
]
142147
choice_set.save()
143148
custom_field.choice_set = choice_set
144149

src/netbox_initializers/initializers/yaml/custom_fields.yml

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,16 +58,18 @@
5858
# required: false
5959
# filter_logic: exact
6060
# weight: 30
61-
# default: First Item
61+
# default: first
6262
# is_cloneable: true
6363
# on_objects:
6464
# - dcim.models.Device
65-
# choices:
66-
# - First Item
67-
# - Second Item
68-
# - Third Item
69-
# - Fifth Item
70-
# - Fourth Item
65+
# # choice_set entries are [value, label] pairs. A plain value may also be
66+
# # given, in which case it is used as both the value and the label.
67+
# choice_set:
68+
# - [first, First Item]
69+
# - [second, Second Item]
70+
# - [third, Third Item]
71+
# - [fourth, Fourth Item]
72+
# - [fifth, Fifth Item]
7173
# boolean_field:
7274
# type: boolean
7375
# label: Yes Or No?

src/netbox_initializers/initializers/yaml/users.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
# test_superuser:
2+
# first_name: Test
3+
# last_name: Admin
4+
# token:
5+
# key: ifiap3Roh1oh # Random identifier (12 characters) for the token, but not secret. #gitleaks:allow
6+
# value: aexophoo0Aizoo2aithi2aofeice7EeMe9shizei6Eghieghoh # must be looooong! #gitleaks:allow
7+
# is_active: true
8+
# is_superuser: true
9+
# email: admin@example.com
110
# technical_user:
211
# token:
312
# key: Xaeboophoo8e # Random identifier (12 characters) for the token, but not secret. #gitleaks:allow

test/test.sh

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,30 @@ test_cleanup() {
4242

4343
test_initializers() {
4444
echo "🏭 Testing Initializers"
45-
$doco run --rm netbox /opt/netbox/docker-entrypoint.sh ./manage.py load_initializer_data --path /etc/netbox/initializer-data || exit 1
45+
$doco run --rm netbox ./manage.py load_initializer_data --path /etc/netbox/initializer-data || exit 1
46+
}
47+
48+
test_api_verification() {
49+
echo "🔍 Verifying data via NetBox API"
50+
51+
# Start the NetBox web server so the REST API is available
52+
$doco up -d netbox || exit 1
53+
54+
# Wait for the API to become available
55+
echo "⏳ Waiting for the NetBox API to be ready"
56+
for _ in $(seq 1 30); do
57+
if $doco exec -T netbox python3 -c \
58+
"import urllib.request; urllib.request.urlopen('http://localhost:8080/api/')" \
59+
>/dev/null 2>&1; then
60+
echo "✅ NetBox API is ready"
61+
break
62+
fi
63+
sleep 5
64+
done
65+
66+
# Copy the verification script into the running container and run it
67+
$doco cp ./verify_api.py netbox:/tmp/verify_api.py || exit 1
68+
$doco exec -T netbox python3 /tmp/verify_api.py || exit 1
4669
}
4770

4871
echo "🐳🐳🐳 Start testing"
@@ -58,4 +81,8 @@ gh_echo "::group::Initializer tests"
5881
test_initializers
5982
gh_echo "::endgroup::"
6083

84+
gh_echo "::group::API verification"
85+
test_api_verification
86+
gh_echo "::endgroup::"
87+
6188
echo "🐳🐳🐳 Done testing"

test/verify_api.py

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
"""Verify NetBox initializer data via the REST API.
2+
3+
The script iterates over every initializer YAML file, and for each object it
4+
defines, queries the NetBox REST API to confirm the object was created. The
5+
expected values are read directly from the YAML files, so there are no
6+
hard-coded test values to keep in sync.
7+
"""
8+
9+
import json
10+
import sys
11+
import urllib.error
12+
import urllib.parse
13+
import urllib.request
14+
from pathlib import Path
15+
from typing import Any
16+
17+
import yaml
18+
19+
BASE_URL = "http://localhost:8080/api"
20+
YAML_DIR = Path("/etc/netbox/initializer-data")
21+
22+
# NetBox v2 API tokens are transmitted as "nbt_<key>.<plaintext>"
23+
TOKEN_PREFIX = "nbt_"
24+
25+
# A single entry in the CHECKS table below.
26+
type Check = dict[str, Any]
27+
type JSON = dict[str, Any]
28+
29+
30+
def load_yaml(name: str) -> Any:
31+
"""Load and parse an initializer YAML file, returning None if absent."""
32+
path = YAML_DIR / name
33+
if not path.exists():
34+
return None
35+
with open(path) as fh:
36+
return yaml.safe_load(fh)
37+
38+
39+
def build_token() -> str | None:
40+
"""Assemble the API token for the first superuser defined in users.yml."""
41+
users = load_yaml("users.yml")
42+
if not users:
43+
return None
44+
for details in users.values():
45+
if details.get("is_superuser"):
46+
token_data = details.get("token", {})
47+
key = token_data.get("key")
48+
value = token_data.get("value")
49+
if key and value:
50+
return f"{TOKEN_PREFIX}{key}.{value}"
51+
return None
52+
53+
54+
TOKEN = build_token()
55+
if not TOKEN:
56+
print("❌ Could not find a superuser token in users.yml")
57+
sys.exit(1)
58+
59+
HEADERS = {
60+
"Authorization": f"Token {TOKEN}",
61+
"Accept": "application/json",
62+
}
63+
64+
65+
def api_get(endpoint: str, params: dict[str, Any] | None = None) -> JSON | None:
66+
"""Make a GET request to the NetBox API.
67+
68+
``endpoint`` may be a path (e.g. ``/dcim/sites/``) or an absolute URL as
69+
returned by the API in nested object references.
70+
"""
71+
url = endpoint if endpoint.startswith("http") else f"{BASE_URL}{endpoint}"
72+
if params:
73+
url = f"{url}?{urllib.parse.urlencode(params)}"
74+
75+
try:
76+
req = urllib.request.Request(url, headers=HEADERS)
77+
with urllib.request.urlopen(req) as response:
78+
return json.loads(response.read().decode())
79+
except urllib.error.HTTPError as e:
80+
print(f"❌ HTTP Error {e.code} on GET {url}: {e.reason}")
81+
return None
82+
except Exception as e: # noqa: BLE001
83+
print(f"❌ Error on GET {url}: {e}")
84+
return None
85+
86+
87+
def choice_value(choice: Any) -> Any:
88+
"""Return the value of a choice, which may be a [value, label] pair."""
89+
return choice[0] if isinstance(choice, (list, tuple)) else choice
90+
91+
92+
# Mapping of each initializer YAML file to how its objects can be located via
93+
# the REST API.
94+
# endpoint: REST API list endpoint
95+
# structure: "list" (list of objects) or "dict" (objects keyed by identifier)
96+
# filters: for "list", the object fields used to build the lookup query
97+
# key_filter: for "dict", the query field the mapping key maps to
98+
CHECKS: list[Check] = [
99+
{"file": "aggregates.yml", "endpoint": "/ipam/aggregates/", "structure": "list", "filters": ["prefix"]},
100+
{"file": "asns.yml", "endpoint": "/ipam/asns/", "structure": "list", "filters": ["asn"]},
101+
{"file": "circuit_types.yml", "endpoint": "/circuits/circuit-types/", "structure": "list", "filters": ["slug"]},
102+
{"file": "circuits.yml", "endpoint": "/circuits/circuits/", "structure": "list", "filters": ["cid"]},
103+
{
104+
"file": "cluster_groups.yml",
105+
"endpoint": "/virtualization/cluster-groups/",
106+
"structure": "list",
107+
"filters": ["slug"],
108+
},
109+
{
110+
"file": "cluster_types.yml",
111+
"endpoint": "/virtualization/cluster-types/",
112+
"structure": "list",
113+
"filters": ["slug"],
114+
},
115+
{"file": "clusters.yml", "endpoint": "/virtualization/clusters/", "structure": "list", "filters": ["name"]},
116+
{"file": "config_contexts.yml", "endpoint": "/extras/config-contexts/", "structure": "list", "filters": ["name"]},
117+
{"file": "config_templates.yml", "endpoint": "/extras/config-templates/", "structure": "list", "filters": ["name"]},
118+
{"file": "contact_groups.yml", "endpoint": "/tenancy/contact-groups/", "structure": "list", "filters": ["slug"]},
119+
{"file": "contact_roles.yml", "endpoint": "/tenancy/contact-roles/", "structure": "list", "filters": ["slug"]},
120+
{"file": "contacts.yml", "endpoint": "/tenancy/contacts/", "structure": "list", "filters": ["name"]},
121+
{"file": "custom_fields.yml", "endpoint": "/extras/custom-fields/", "structure": "dict", "key_filter": "name"},
122+
{"file": "custom_links.yml", "endpoint": "/extras/custom-links/", "structure": "list", "filters": ["name"]},
123+
{"file": "device_roles.yml", "endpoint": "/dcim/device-roles/", "structure": "list", "filters": ["slug"]},
124+
{"file": "device_types.yml", "endpoint": "/dcim/device-types/", "structure": "list", "filters": ["slug"]},
125+
{"file": "devices.yml", "endpoint": "/dcim/devices/", "structure": "list", "filters": ["name"]},
126+
{"file": "groups.yml", "endpoint": "/users/groups/", "structure": "dict", "key_filter": "name"},
127+
{"file": "interfaces.yml", "endpoint": "/dcim/interfaces/", "structure": "list", "filters": ["device", "name"]},
128+
{"file": "ip_addresses.yml", "endpoint": "/ipam/ip-addresses/", "structure": "list", "filters": ["address"]},
129+
{"file": "locations.yml", "endpoint": "/dcim/locations/", "structure": "list", "filters": ["slug"]},
130+
{"file": "macs.yml", "endpoint": "/dcim/mac-addresses/", "structure": "list", "filters": ["mac_address"]},
131+
{"file": "manufacturers.yml", "endpoint": "/dcim/manufacturers/", "structure": "list", "filters": ["slug"]},
132+
{"file": "object_permissions.yml", "endpoint": "/users/permissions/", "structure": "dict", "key_filter": "name"},
133+
{"file": "platforms.yml", "endpoint": "/dcim/platforms/", "structure": "list", "filters": ["slug"]},
134+
{"file": "power_feeds.yml", "endpoint": "/dcim/power-feeds/", "structure": "list", "filters": ["name"]},
135+
{"file": "power_panels.yml", "endpoint": "/dcim/power-panels/", "structure": "list", "filters": ["name"]},
136+
{"file": "prefix_vlan_roles.yml", "endpoint": "/ipam/roles/", "structure": "list", "filters": ["slug"]},
137+
{"file": "prefixes.yml", "endpoint": "/ipam/prefixes/", "structure": "list", "filters": ["prefix"]},
138+
{"file": "providers.yml", "endpoint": "/circuits/providers/", "structure": "list", "filters": ["slug"]},
139+
{"file": "rack_roles.yml", "endpoint": "/dcim/rack-roles/", "structure": "list", "filters": ["slug"]},
140+
{"file": "rack_types.yml", "endpoint": "/dcim/rack-types/", "structure": "list", "filters": ["slug"]},
141+
{"file": "racks.yml", "endpoint": "/dcim/racks/", "structure": "list", "filters": ["name"]},
142+
{"file": "regions.yml", "endpoint": "/dcim/regions/", "structure": "list", "filters": ["slug"]},
143+
{"file": "rirs.yml", "endpoint": "/ipam/rirs/", "structure": "list", "filters": ["slug"]},
144+
{"file": "route_targets.yml", "endpoint": "/ipam/route-targets/", "structure": "list", "filters": ["name"]},
145+
{"file": "service_templates.yml", "endpoint": "/ipam/service-templates/", "structure": "list", "filters": ["name"]},
146+
{"file": "services.yml", "endpoint": "/ipam/services/", "structure": "list", "filters": ["name"]},
147+
{"file": "site_groups.yml", "endpoint": "/dcim/site-groups/", "structure": "list", "filters": ["slug"]},
148+
{"file": "sites.yml", "endpoint": "/dcim/sites/", "structure": "list", "filters": ["slug"]},
149+
{"file": "tags.yml", "endpoint": "/extras/tags/", "structure": "list", "filters": ["slug"]},
150+
{"file": "tenant_groups.yml", "endpoint": "/tenancy/tenant-groups/", "structure": "list", "filters": ["slug"]},
151+
{"file": "tenants.yml", "endpoint": "/tenancy/tenants/", "structure": "list", "filters": ["slug"]},
152+
{"file": "users.yml", "endpoint": "/users/users/", "structure": "dict", "key_filter": "username"},
153+
{
154+
"file": "virtual_machines.yml",
155+
"endpoint": "/virtualization/virtual-machines/",
156+
"structure": "list",
157+
"filters": ["name"],
158+
},
159+
{
160+
"file": "virtualization_interfaces.yml",
161+
"endpoint": "/virtualization/interfaces/",
162+
"structure": "list",
163+
"filters": ["virtual_machine", "name"],
164+
},
165+
{"file": "vlan_groups.yml", "endpoint": "/ipam/vlan-groups/", "structure": "list", "filters": ["slug"]},
166+
{"file": "vlans.yml", "endpoint": "/ipam/vlans/", "structure": "list", "filters": ["vid"]},
167+
{"file": "vrfs.yml", "endpoint": "/ipam/vrfs/", "structure": "list", "filters": ["name"]},
168+
{"file": "webhooks.yml", "endpoint": "/extras/webhooks/", "structure": "list", "filters": ["name"]},
169+
# Cables have no simple natural key; verify the expected number were created.
170+
{"file": "cables.yml", "endpoint": "/dcim/cables/", "structure": "count"},
171+
]
172+
173+
174+
class Verifier:
175+
def __init__(self) -> None:
176+
self.passed = 0
177+
self.failed = 0
178+
179+
def ok(self, message: str) -> None:
180+
self.passed += 1
181+
print(f"✅ {message}")
182+
183+
def fail(self, message: str) -> None:
184+
self.failed += 1
185+
print(f"❌ {message}")
186+
187+
def object_exists(self, endpoint: str, filters: dict[str, Any]) -> bool:
188+
data = api_get(endpoint, filters)
189+
return bool(data and data.get("results"))
190+
191+
def verify_custom_field_choices(self, cf: JSON, details: JSON, label: str) -> None:
192+
"""For select/multiselect fields, confirm the choice set matches the YAML."""
193+
expected = details.get("choice_set")
194+
if not expected or not cf.get("choice_set"):
195+
return
196+
choice_data = api_get(cf["choice_set"]["url"])
197+
if not choice_data:
198+
self.fail(f"{label}: choice set could not be retrieved")
199+
return
200+
actual = {choice_value(c) for c in choice_data.get("extra_choices", [])}
201+
wanted = {choice_value(c) for c in expected}
202+
if actual != wanted:
203+
self.fail(f"{label}: choices {sorted(actual)} != expected {sorted(wanted)}")
204+
else:
205+
self.ok(f"{label}: choice set verified")
206+
207+
def run_check(self, check: Check) -> None:
208+
data = load_yaml(check["file"])
209+
if not data:
210+
return
211+
obj = check["file"].removesuffix(".yml")
212+
213+
match check["structure"]:
214+
case "count":
215+
self._check_count(check, obj, data)
216+
case "dict":
217+
self._check_dict(check, obj, data)
218+
case "list":
219+
self._check_list(check, obj, data)
220+
221+
def _check_count(self, check: Check, obj: str, data: list) -> None:
222+
resp = api_get(check["endpoint"], {"limit": 1})
223+
count = resp.get("count") if resp else None
224+
if count is not None and count >= len(data):
225+
self.ok(f"{obj}: {count} object(s) present (>= {len(data)} defined)")
226+
else:
227+
self.fail(f"{obj}: expected >= {len(data)} objects, API reports {count}")
228+
229+
def _check_dict(self, check: Check, obj: str, data: dict) -> None:
230+
endpoint = check["endpoint"]
231+
key_filter = check["key_filter"]
232+
for key, details in data.items():
233+
label = f"{obj} '{key}'"
234+
result = api_get(endpoint, {key_filter: key})
235+
if result and result.get("results"):
236+
self.ok(f"{label} verified")
237+
if obj == "custom_fields":
238+
self.verify_custom_field_choices(result["results"][0], details, label)
239+
else:
240+
self.fail(f"{label} not found")
241+
242+
def _check_list(self, check: Check, obj: str, data: list) -> None:
243+
for item in data:
244+
filters = {f: item[f] for f in check["filters"] if item.get(f) is not None}
245+
if not filters:
246+
continue
247+
label_value = filters.get(check["filters"][-1], next(iter(filters.values())))
248+
label = f"{obj} '{label_value}'"
249+
if self.object_exists(check["endpoint"], filters):
250+
self.ok(f"{label} verified")
251+
else:
252+
self.fail(f"{label} not found")
253+
254+
def run(self) -> int:
255+
print("🔍 Verifying NetBox initializer data via API")
256+
print(f" Base URL: {BASE_URL}")
257+
print(f" Loading config from: {YAML_DIR}\n")
258+
259+
for check in CHECKS:
260+
self.run_check(check)
261+
262+
total = self.passed + self.failed
263+
print(f"\n📊 Verification Results: {self.passed}/{total} checks passed")
264+
if self.failed == 0:
265+
print("✨ All verifications passed!")
266+
return 0
267+
print(f"⚠️ {self.failed} verification(s) failed")
268+
return 1
269+
270+
271+
if __name__ == "__main__":
272+
sys.exit(Verifier().run())

0 commit comments

Comments
 (0)